Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
622659f6ca | ||
|
|
93ca8a76c3 | ||
|
|
69f50064a3 | ||
|
|
e7b81f24bd | ||
|
|
20a310a4b1 | ||
|
|
b6859706db | ||
|
|
ea148839d3 |
@@ -8,16 +8,11 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
testflight:
|
testflight:
|
||||||
runs-on: xcode
|
runs-on: macos-arm64
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup Ruby
|
- name: Setup Ruby
|
||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@v1
|
||||||
@@ -27,16 +22,11 @@ jobs:
|
|||||||
working-directory: ios
|
working-directory: ios
|
||||||
|
|
||||||
- name: Install XcodeGen
|
- name: Install XcodeGen
|
||||||
run: |
|
run: command -v xcodegen >/dev/null 2>&1 || brew install xcodegen
|
||||||
set -euo pipefail
|
|
||||||
if ! command -v xcodegen >/dev/null 2>&1; then
|
|
||||||
brew install xcodegen
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload to TestFlight
|
- name: Upload to TestFlight
|
||||||
working-directory: ios
|
working-directory: ios
|
||||||
env:
|
env:
|
||||||
HOME: /var/lib/act_runner
|
|
||||||
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||||
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
|
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
|
||||||
APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }}
|
APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }}
|
||||||
@@ -46,7 +36,9 @@ jobs:
|
|||||||
SYBIL_BUILD_NUMBER: ${{ github.run_number }}
|
SYBIL_BUILD_NUMBER: ${{ github.run_number }}
|
||||||
FASTLANE_SKIP_UPDATE_CHECK: "1"
|
FASTLANE_SKIP_UPDATE_CHECK: "1"
|
||||||
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: "120"
|
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: "120"
|
||||||
|
# act_runner does not propagate setup-ruby's PATH changes into later
|
||||||
|
# steps, so put the selected Ruby back on PATH or `bundle` resolves to
|
||||||
|
# the toolcache default and misses the gems installed above.
|
||||||
run: |
|
run: |
|
||||||
export PATH="/Users/runner/hostedtoolcache/Ruby/3.1.7/arm64/bin:${PATH}"
|
export PATH="/Users/runner/hostedtoolcache/Ruby/3.1.7/arm64/bin:${PATH}"
|
||||||
ruby --version
|
|
||||||
bundle exec fastlane ios beta
|
bundle exec fastlane ios beta
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ services:
|
|||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||||
XAI_API_KEY: ${XAI_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_BASE_URL: ${HERMES_AGENT_API_BASE_URL:-http://127.0.0.1:8642/v1}
|
||||||
HERMES_AGENT_API_KEY: ${HERMES_AGENT_API_KEY:-}
|
HERMES_AGENT_API_KEY: ${HERMES_AGENT_API_KEY:-}
|
||||||
HERMES_AGENT_MODEL: ${HERMES_AGENT_MODEL:-}
|
HERMES_AGENT_MODEL: ${HERMES_AGENT_MODEL:-}
|
||||||
|
|||||||
+12
-8
@@ -34,11 +34,13 @@ Chat upload limits:
|
|||||||
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
|
"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 },
|
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
|
||||||
"xai": { "models": ["grok-3-mini"], "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 }
|
"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.
|
- 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.
|
- `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.
|
- 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:
|
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.
|
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
|
||||||
|
|
||||||
## Active Runs
|
## Active Runs
|
||||||
@@ -128,7 +130,7 @@ Behavior notes:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"title": "optional title",
|
"title": "optional title",
|
||||||
"provider": "optional openai|anthropic|xai|hermes-agent",
|
"provider": "optional openai|anthropic|xai|gemini|hermes-agent",
|
||||||
"model": "optional model id",
|
"model": "optional model id",
|
||||||
"additionalSystemPrompt": "optional stored system prompt",
|
"additionalSystemPrompt": "optional stored system prompt",
|
||||||
"enabledTools": ["web_search", "fetch_url"],
|
"enabledTools": ["web_search", "fetch_url"],
|
||||||
@@ -234,7 +236,7 @@ Notes:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"chatId": "optional-chat-id",
|
"chatId": "optional-chat-id",
|
||||||
"provider": "openai|anthropic|xai|hermes-agent",
|
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
||||||
"model": "string",
|
"model": "string",
|
||||||
"messages": [
|
"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 `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 `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 `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 `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 `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 `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 `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.
|
- 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`.
|
- `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).
|
- `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 <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
|
- `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 <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
|
||||||
@@ -417,9 +421,9 @@ Behavior notes:
|
|||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
"starred": false,
|
"starred": false,
|
||||||
"starredAt": null,
|
"starredAt": null,
|
||||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||||
"initiatedModel": "string|null",
|
"initiatedModel": "string|null",
|
||||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||||
"lastUsedModel": "string|null",
|
"lastUsedModel": "string|null",
|
||||||
"additionalSystemPrompt": null,
|
"additionalSystemPrompt": null,
|
||||||
"enabledTools": ["web_search", "fetch_url"]
|
"enabledTools": ["web_search", "fetch_url"]
|
||||||
@@ -469,9 +473,9 @@ Behavior notes:
|
|||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
"starred": false,
|
"starred": false,
|
||||||
"starredAt": null,
|
"starredAt": null,
|
||||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||||
"initiatedModel": "string|null",
|
"initiatedModel": "string|null",
|
||||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
|
||||||
"lastUsedModel": "string|null",
|
"lastUsedModel": "string|null",
|
||||||
"additionalSystemPrompt": null,
|
"additionalSystemPrompt": null,
|
||||||
"enabledTools": ["web_search", "fetch_url"],
|
"enabledTools": ["web_search", "fetch_url"],
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Authentication:
|
|||||||
{
|
{
|
||||||
"chatId": "optional-chat-id",
|
"chatId": "optional-chat-id",
|
||||||
"persist": true,
|
"persist": true,
|
||||||
"provider": "openai|anthropic|xai|hermes-agent",
|
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
||||||
"model": "string",
|
"model": "string",
|
||||||
"messages": [
|
"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.
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- `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 still emits standard `meta`, `delta`, `done|error` events.
|
||||||
- Stream may emit `tool_call` events while tool calls are executed.
|
- 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.
|
- `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.
|
||||||
|
|||||||
@@ -57,4 +57,5 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
|||||||
- OpenAI: `gpt-4.1-mini`
|
- OpenAI: `gpt-4.1-mini`
|
||||||
- Anthropic: `claude-3-5-sonnet-latest`
|
- Anthropic: `claude-3-5-sonnet-latest`
|
||||||
- xAI: `grok-3-mini`
|
- xAI: `grok-3-mini`
|
||||||
|
- Gemini: `gemini-3.5-flash`
|
||||||
- Hermes Agent: `hermes-agent`
|
- Hermes Agent: `hermes-agent`
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ struct SybilChatTranscriptView: View {
|
|||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
syncKnownToolCallMessageIDs()
|
syncKnownToolCallMessageIDs()
|
||||||
scrollToBottom(with: proxy, animated: false)
|
|
||||||
}
|
}
|
||||||
.onChange(of: toolCallMessageIDSignature) { _, _ in
|
.onChange(of: toolCallMessageIDSignature) { _, _ in
|
||||||
syncKnownToolCallMessageIDs()
|
syncKnownToolCallMessageIDs()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
|
|||||||
case openai
|
case openai
|
||||||
case anthropic
|
case anthropic
|
||||||
case xai
|
case xai
|
||||||
|
case gemini
|
||||||
case hermesAgent = "hermes-agent"
|
case hermesAgent = "hermes-agent"
|
||||||
|
|
||||||
public var displayName: String {
|
public var displayName: String {
|
||||||
@@ -11,6 +12,7 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
|
|||||||
case .openai: return "OpenAI"
|
case .openai: return "OpenAI"
|
||||||
case .anthropic: return "Anthropic"
|
case .anthropic: return "Anthropic"
|
||||||
case .xai: return "xAI"
|
case .xai: return "xAI"
|
||||||
|
case .gemini: return "Gemini"
|
||||||
case .hermesAgent: return "Hermes Agent"
|
case .hermesAgent: return "Hermes Agent"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ final class SybilSettingsStore {
|
|||||||
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
|
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
|
||||||
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
|
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
|
||||||
static let preferredXAIModel = "sybil.ios.preferredXAIModel"
|
static let preferredXAIModel = "sybil.ios.preferredXAIModel"
|
||||||
|
static let preferredGeminiModel = "sybil.ios.preferredGeminiModel"
|
||||||
static let preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel"
|
static let preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel"
|
||||||
static let quickQuestionPreferredProvider = "sybil.ios.quickQuestionPreferredProvider"
|
static let quickQuestionPreferredProvider = "sybil.ios.quickQuestionPreferredProvider"
|
||||||
static let quickQuestionPreferredOpenAIModel = "sybil.ios.quickQuestionPreferredOpenAIModel"
|
static let quickQuestionPreferredOpenAIModel = "sybil.ios.quickQuestionPreferredOpenAIModel"
|
||||||
static let quickQuestionPreferredAnthropicModel = "sybil.ios.quickQuestionPreferredAnthropicModel"
|
static let quickQuestionPreferredAnthropicModel = "sybil.ios.quickQuestionPreferredAnthropicModel"
|
||||||
static let quickQuestionPreferredXAIModel = "sybil.ios.quickQuestionPreferredXAIModel"
|
static let quickQuestionPreferredXAIModel = "sybil.ios.quickQuestionPreferredXAIModel"
|
||||||
|
static let quickQuestionPreferredGeminiModel = "sybil.ios.quickQuestionPreferredGeminiModel"
|
||||||
static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel"
|
static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +46,7 @@ final class SybilSettingsStore {
|
|||||||
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
|
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
|
||||||
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
|
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
|
||||||
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini",
|
.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"
|
.hermesAgent: defaults.string(forKey: Keys.preferredHermesAgentModel) ?? "hermes-agent"
|
||||||
]
|
]
|
||||||
self.preferredModelByProvider = preferredModels
|
self.preferredModelByProvider = preferredModels
|
||||||
@@ -54,6 +57,7 @@ final class SybilSettingsStore {
|
|||||||
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini",
|
.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",
|
.anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
|
||||||
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
|
.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"
|
.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[.openai], forKey: Keys.preferredOpenAIModel)
|
||||||
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
|
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
|
||||||
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
|
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
|
||||||
|
defaults.set(preferredModelByProvider[.gemini], forKey: Keys.preferredGeminiModel)
|
||||||
defaults.set(preferredModelByProvider[.hermesAgent], forKey: Keys.preferredHermesAgentModel)
|
defaults.set(preferredModelByProvider[.hermesAgent], forKey: Keys.preferredHermesAgentModel)
|
||||||
|
|
||||||
defaults.set(quickQuestionPreferredProvider.rawValue, forKey: Keys.quickQuestionPreferredProvider)
|
defaults.set(quickQuestionPreferredProvider.rawValue, forKey: Keys.quickQuestionPreferredProvider)
|
||||||
defaults.set(quickQuestionPreferredModelByProvider[.openai], forKey: Keys.quickQuestionPreferredOpenAIModel)
|
defaults.set(quickQuestionPreferredModelByProvider[.openai], forKey: Keys.quickQuestionPreferredOpenAIModel)
|
||||||
defaults.set(quickQuestionPreferredModelByProvider[.anthropic], forKey: Keys.quickQuestionPreferredAnthropicModel)
|
defaults.set(quickQuestionPreferredModelByProvider[.anthropic], forKey: Keys.quickQuestionPreferredAnthropicModel)
|
||||||
defaults.set(quickQuestionPreferredModelByProvider[.xai], forKey: Keys.quickQuestionPreferredXAIModel)
|
defaults.set(quickQuestionPreferredModelByProvider[.xai], forKey: Keys.quickQuestionPreferredXAIModel)
|
||||||
|
defaults.set(quickQuestionPreferredModelByProvider[.gemini], forKey: Keys.quickQuestionPreferredGeminiModel)
|
||||||
defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel)
|
defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ final class SybilViewModel {
|
|||||||
.openai: ["gpt-4.1-mini"],
|
.openai: ["gpt-4.1-mini"],
|
||||||
.anthropic: ["claude-3-5-sonnet-latest"],
|
.anthropic: ["claude-3-5-sonnet-latest"],
|
||||||
.xai: ["grok-3-mini"],
|
.xai: ["grok-3-mini"],
|
||||||
|
.gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
||||||
.hermesAgent: ["hermes-agent"]
|
.hermesAgent: ["hermes-agent"]
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1751,13 +1752,16 @@ final class SybilViewModel {
|
|||||||
switch target {
|
switch target {
|
||||||
case let .chat(chatID):
|
case let .chat(chatID):
|
||||||
SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)")
|
SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)")
|
||||||
|
let isSelectingDifferentChat = selectedChat?.id != chatID
|
||||||
let chat = try await client.getChat(chatID: chatID)
|
let chat = try await client.getChat(chatID: chatID)
|
||||||
guard selectedItem == target, draftKind == nil else {
|
guard selectedItem == target, draftKind == nil else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
selectedChat = chat
|
selectedChat = chat
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
requestChatBottomPin()
|
if isSelectingDifferentChat {
|
||||||
|
requestChatBottomPin()
|
||||||
|
}
|
||||||
|
|
||||||
if let provider = chat.lastUsedProvider,
|
if let provider = chat.lastUsedProvider,
|
||||||
let model = chat.lastUsedModel,
|
let model = chat.lastUsedModel,
|
||||||
|
|||||||
@@ -544,12 +544,14 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
@MainActor
|
@MainActor
|
||||||
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
|
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_100)
|
let date = Date(timeIntervalSince1970: 1_700_000_100)
|
||||||
|
let staleDetail = makeChatDetail(id: "chat-2", date: date, body: "stale transcript")
|
||||||
let detail = makeChatDetail(id: "chat-2", date: date, body: "refreshed transcript")
|
let detail = makeChatDetail(id: "chat-2", date: date, body: "refreshed transcript")
|
||||||
let client = MockSybilClient(chatDetails: ["chat-2": detail])
|
let client = MockSybilClient(chatDetails: ["chat-2": detail])
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
viewModel.isAuthenticated = true
|
viewModel.isAuthenticated = true
|
||||||
viewModel.isCheckingSession = false
|
viewModel.isCheckingSession = false
|
||||||
viewModel.selectedItem = .chat("chat-2")
|
viewModel.selectedItem = .chat("chat-2")
|
||||||
|
viewModel.selectedChat = staleDetail
|
||||||
|
|
||||||
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
||||||
|
|
||||||
@@ -559,7 +561,7 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
#expect(snapshot.listSearches == 0)
|
#expect(snapshot.listSearches == 0)
|
||||||
#expect(snapshot.getChat == 1)
|
#expect(snapshot.getChat == 1)
|
||||||
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
||||||
#expect(viewModel.chatBottomPinRequestID == 1)
|
#expect(viewModel.chatBottomPinRequestID == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -675,6 +677,7 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
|
|
||||||
#expect(viewModel.displayedMessages.first?.content == "fresh transcript")
|
#expect(viewModel.displayedMessages.first?.content == "fresh transcript")
|
||||||
#expect(!viewModel.isLoadingSelection)
|
#expect(!viewModel.isLoadingSelection)
|
||||||
|
#expect(viewModel.chatBottomPinRequestID == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
+24
-29
@@ -8,8 +8,7 @@ TEAM_ID = "DQQH5H6GBD"
|
|||||||
PROFILE_NAME = "Sybil AppStore CI"
|
PROFILE_NAME = "Sybil AppStore CI"
|
||||||
CI_KEYCHAIN_NAME = "sybil_ci_keychain"
|
CI_KEYCHAIN_NAME = "sybil_ci_keychain"
|
||||||
CI_KEYCHAIN_PASSWORD = "sybil-ci-keychain-password"
|
CI_KEYCHAIN_PASSWORD = "sybil-ci-keychain-password"
|
||||||
CI_KEYCHAIN_PATH = File.expand_path("~/Library/Keychains/#{CI_KEYCHAIN_NAME}")
|
CI_KEYCHAIN_DB_PATH = File.expand_path("~/Library/Keychains/#{CI_KEYCHAIN_NAME}-db")
|
||||||
CI_KEYCHAIN_DB_PATH = "#{CI_KEYCHAIN_PATH}-db"
|
|
||||||
IOS_ROOT = File.expand_path("..", __dir__)
|
IOS_ROOT = File.expand_path("..", __dir__)
|
||||||
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
|
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
|
||||||
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
|
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
|
||||||
@@ -62,26 +61,6 @@ def stamp_marketing_version(version)
|
|||||||
File.write(APP_PROJECT_SPEC, updated)
|
File.write(APP_PROJECT_SPEC, updated)
|
||||||
end
|
end
|
||||||
|
|
||||||
def ci_keychain_path
|
|
||||||
File.file?(CI_KEYCHAIN_DB_PATH) ? CI_KEYCHAIN_DB_PATH : CI_KEYCHAIN_PATH
|
|
||||||
end
|
|
||||||
|
|
||||||
def signing_xcargs
|
|
||||||
args = [
|
|
||||||
"DEVELOPMENT_TEAM=#{TEAM_ID.shellescape}",
|
|
||||||
"CODE_SIGN_STYLE=Manual",
|
|
||||||
"CODE_SIGN_IDENTITY=Apple\\ Distribution",
|
|
||||||
"PROVISIONING_PROFILE_SPECIFIER=#{PROFILE_NAME.shellescape}"
|
|
||||||
]
|
|
||||||
|
|
||||||
if ci?
|
|
||||||
args << "CODE_SIGN_KEYCHAIN=#{ci_keychain_path.shellescape}"
|
|
||||||
args << "OTHER_CODE_SIGN_FLAGS=#{("--keychain #{ci_keychain_path}").shellescape}"
|
|
||||||
end
|
|
||||||
|
|
||||||
args.join(" ")
|
|
||||||
end
|
|
||||||
|
|
||||||
platform :ios do
|
platform :ios do
|
||||||
private_lane :app_store_api_key do
|
private_lane :app_store_api_key do
|
||||||
app_store_connect_api_key(
|
app_store_connect_api_key(
|
||||||
@@ -92,21 +71,31 @@ platform :ios do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
# CI uses a dedicated throwaway keychain for match. build_app passes this
|
# CI signs headlessly, so match needs a fresh unlocked keychain to import
|
||||||
# keychain explicitly so codesign does not depend on the runner's ambient
|
# into. codesign resolves identities through the user keychain *search list*
|
||||||
# login/default keychain state.
|
# (first match wins; the --keychain flag does not restrict the lookup), and
|
||||||
|
# other projects' keychains on this runner hold the same identity but are
|
||||||
|
# usually locked — so ours must come first. delete_keychain in the beta
|
||||||
|
# lane's ensure removes both the keychain and its search-list entry, which
|
||||||
|
# also keeps our (later locked) copy from shadowing those other projects.
|
||||||
private_lane :prepare_ci_keychain do
|
private_lane :prepare_ci_keychain do
|
||||||
next unless ci?
|
next unless ci?
|
||||||
|
|
||||||
delete_keychain(name: CI_KEYCHAIN_NAME) if File.file?(CI_KEYCHAIN_DB_PATH) || File.file?(CI_KEYCHAIN_PATH)
|
delete_keychain(name: CI_KEYCHAIN_NAME) if File.file?(CI_KEYCHAIN_DB_PATH)
|
||||||
create_keychain(
|
create_keychain(
|
||||||
name: CI_KEYCHAIN_NAME,
|
name: CI_KEYCHAIN_NAME,
|
||||||
password: CI_KEYCHAIN_PASSWORD,
|
password: CI_KEYCHAIN_PASSWORD,
|
||||||
unlock: true,
|
unlock: true,
|
||||||
timeout: 3600,
|
timeout: 3600,
|
||||||
add_to_search_list: true
|
add_to_search_list: false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
others = sh("security list-keychains -d user", log: false)
|
||||||
|
.scan(/"([^"]+)"/)
|
||||||
|
.flatten
|
||||||
|
.reject { |path| path.include?(CI_KEYCHAIN_NAME) }
|
||||||
|
sh("security list-keychains -d user -s #{([CI_KEYCHAIN_DB_PATH] + others).shelljoin}")
|
||||||
|
|
||||||
ENV["MATCH_KEYCHAIN_NAME"] = CI_KEYCHAIN_NAME
|
ENV["MATCH_KEYCHAIN_NAME"] = CI_KEYCHAIN_NAME
|
||||||
ENV["MATCH_KEYCHAIN_PASSWORD"] = CI_KEYCHAIN_PASSWORD
|
ENV["MATCH_KEYCHAIN_PASSWORD"] = CI_KEYCHAIN_PASSWORD
|
||||||
end
|
end
|
||||||
@@ -150,8 +139,12 @@ platform :ios do
|
|||||||
project: PROJECT_FILE,
|
project: PROJECT_FILE,
|
||||||
scheme: SCHEME,
|
scheme: SCHEME,
|
||||||
export_method: "app-store",
|
export_method: "app-store",
|
||||||
codesigning_identity: "Apple Distribution",
|
xcargs: [
|
||||||
xcargs: signing_xcargs,
|
"DEVELOPMENT_TEAM=#{TEAM_ID.shellescape}",
|
||||||
|
"CODE_SIGN_STYLE=Manual",
|
||||||
|
"CODE_SIGN_IDENTITY=Apple\\ Distribution",
|
||||||
|
"PROVISIONING_PROFILE_SPECIFIER=#{PROFILE_NAME.shellescape}"
|
||||||
|
].join(" "),
|
||||||
export_options: {
|
export_options: {
|
||||||
signingStyle: "manual",
|
signingStyle: "manual",
|
||||||
teamID: TEAM_ID,
|
teamID: TEAM_ID,
|
||||||
@@ -165,5 +158,7 @@ platform :ios do
|
|||||||
api_key: api_key,
|
api_key: api_key,
|
||||||
skip_waiting_for_build_processing: true
|
skip_waiting_for_build_processing: true
|
||||||
)
|
)
|
||||||
|
ensure
|
||||||
|
delete_keychain(name: CI_KEYCHAIN_NAME) if ci? && File.file?(CI_KEYCHAIN_DB_PATH)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+4
-3
@@ -1,7 +1,7 @@
|
|||||||
# Sybil Server
|
# Sybil Server
|
||||||
|
|
||||||
Backend API for:
|
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)
|
- Personal chat database (chats/messages + LLM call log)
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
@@ -43,6 +43,7 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
|
|||||||
- `OPENAI_API_KEY`
|
- `OPENAI_API_KEY`
|
||||||
- `ANTHROPIC_API_KEY`
|
- `ANTHROPIC_API_KEY`
|
||||||
- `XAI_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_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_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`)
|
- `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)
|
- `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`)
|
- `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_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_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_USER` (optional SSH user when host does not include one)
|
||||||
- `CHAT_CODEX_REMOTE_PORT` (`22` by default)
|
- `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_KEY_PATH` (recommended: path to a read-only mounted private key)
|
||||||
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64` (optional fallback private key delivery)
|
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64` (optional fallback private key delivery)
|
||||||
- `CHAT_CODEX_EXEC_TIMEOUT_MS` (`600000` by default)
|
- `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)
|
- `CHAT_SHELL_EXEC_TIMEOUT_MS` (`120000` by default)
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ enum Provider {
|
|||||||
openai
|
openai
|
||||||
anthropic
|
anthropic
|
||||||
xai
|
xai
|
||||||
|
gemini
|
||||||
hermes_agent @map("hermes-agent")
|
hermes_agent @map("hermes-agent")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ const EnvSchema = z.object({
|
|||||||
OPENAI_API_KEY: z.string().optional(),
|
OPENAI_API_KEY: z.string().optional(),
|
||||||
ANTHROPIC_API_KEY: z.string().optional(),
|
ANTHROPIC_API_KEY: z.string().optional(),
|
||||||
XAI_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_BASE_URL: HermesAgentApiBaseUrlSchema,
|
||||||
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
|
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
|
||||||
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
|
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
|
||||||
|
|||||||
@@ -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<string, string> = {}) {
|
||||||
|
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<ToolAwareCompletionParams, "temperature" | "maxTokens">) {
|
||||||
|
const config: Record<string, unknown> = {};
|
||||||
|
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<string, unknown> | undefined {
|
||||||
|
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return undefined;
|
||||||
|
const input = schema as Record<string, unknown>;
|
||||||
|
const output: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
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<string, unknown> = {};
|
||||||
|
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<string, unknown> = {
|
||||||
|
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<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
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<ToolAwareUsage>, 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<string, unknown>) {
|
||||||
|
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<ToolAwareCompletionResult> {
|
||||||
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const conversation = buildBaseContents(params.messages);
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
|
const usageAcc: Required<ToolAwareUsage> = { 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<string, unknown>) {
|
||||||
|
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<ToolAwareStreamingEvent> {
|
||||||
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const conversation = buildBaseContents(params.messages);
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
|
const usageAcc: Required<ToolAwareUsage> = { 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,10 +5,11 @@ import {
|
|||||||
type ToolAwareStreamingEvent,
|
type ToolAwareStreamingEvent,
|
||||||
} from "./chat-tools.js";
|
} from "./chat-tools.js";
|
||||||
import { completeWithChatCompletionsApi, streamWithChatCompletionsApi } from "./protocols/chat-completions-api.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 { completeWithMessagesApi, streamWithMessagesApi } from "./protocols/messages-api.js";
|
||||||
import { completeWithResponsesApi, streamWithResponsesApi } from "./protocols/responses-api.js";
|
import { completeWithResponsesApi, streamWithResponsesApi } from "./protocols/responses-api.js";
|
||||||
import { env } from "../env.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";
|
import type { ChatMessage, Provider } from "./types.js";
|
||||||
|
|
||||||
type ProviderAdapterParams = {
|
type ProviderAdapterParams = {
|
||||||
@@ -27,7 +28,7 @@ export type ProviderChatAdapter = {
|
|||||||
stream(params: ProviderAdapterParams): AsyncGenerator<ToolAwareStreamingEvent>;
|
stream(params: ProviderAdapterParams): AsyncGenerator<ToolAwareStreamingEvent>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChatProtocolId = "chat-completions" | "messages" | "responses";
|
type ChatProtocolId = "chat-completions" | "gemini" | "messages" | "responses";
|
||||||
|
|
||||||
type ChatProtocol = {
|
type ChatProtocol = {
|
||||||
id: ChatProtocolId;
|
id: ChatProtocolId;
|
||||||
@@ -39,6 +40,7 @@ type ModelCatalogSpec = {
|
|||||||
enabled?: () => boolean;
|
enabled?: () => boolean;
|
||||||
fetchModels(client: any): Promise<string[]>;
|
fetchModels(client: any): Promise<string[]>;
|
||||||
fallbackModels?: () => string[];
|
fallbackModels?: () => string[];
|
||||||
|
sortModels?: (models: string[]) => string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProviderBackendSpec = {
|
type ProviderBackendSpec = {
|
||||||
@@ -61,6 +63,12 @@ const messagesProtocol: ChatProtocol = {
|
|||||||
stream: streamWithMessagesApi,
|
stream: streamWithMessagesApi,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const geminiProtocol: ChatProtocol = {
|
||||||
|
id: "gemini",
|
||||||
|
complete: completeWithGeminiApi,
|
||||||
|
stream: streamWithGeminiApi,
|
||||||
|
};
|
||||||
|
|
||||||
const responsesProtocol: ChatProtocol = {
|
const responsesProtocol: ChatProtocol = {
|
||||||
id: "responses",
|
id: "responses",
|
||||||
complete: completeWithResponsesApi,
|
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) {
|
function isLikelyResponsesApiModel(model: string) {
|
||||||
const id = model.toLowerCase();
|
const id = model.toLowerCase();
|
||||||
if (id.includes("embedding") || id.includes("moderation")) return false;
|
if (id.includes("embedding") || id.includes("moderation")) return false;
|
||||||
@@ -86,6 +98,37 @@ function isLikelyResponsesApiModel(model: string) {
|
|||||||
return /^(gpt-|o\d|chatgpt-)/.test(id);
|
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<any> {
|
||||||
|
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 {
|
function withClient(params: ProviderAdapterParams, client: any, enabledTools?: string[]): ToolAwareCompletionParams {
|
||||||
return {
|
return {
|
||||||
client,
|
client,
|
||||||
@@ -160,6 +203,29 @@ const backendSpecs: Record<Provider, ProviderBackendSpec> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
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": {
|
"hermes-agent": {
|
||||||
createClient: hermesAgentClient,
|
createClient: hermesAgentClient,
|
||||||
plainProtocol: chatCompletionsProtocol,
|
plainProtocol: chatCompletionsProtocol,
|
||||||
@@ -209,7 +275,8 @@ export function listModelCatalogProviders(): Provider[] {
|
|||||||
export async function fetchProviderCatalogModels(provider: Provider) {
|
export async function fetchProviderCatalogModels(provider: Provider) {
|
||||||
const spec = backendSpecs[provider].modelCatalog;
|
const spec = backendSpecs[provider].modelCatalog;
|
||||||
if (!spec) return [];
|
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) {
|
export function getProviderCatalogFallbackModels(provider: Provider) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const apiToPrismaProvider = {
|
|||||||
openai: "openai",
|
openai: "openai",
|
||||||
anthropic: "anthropic",
|
anthropic: "anthropic",
|
||||||
xai: "xai",
|
xai: "xai",
|
||||||
|
gemini: "gemini",
|
||||||
"hermes-agent": "hermes_agent",
|
"hermes-agent": "hermes_agent",
|
||||||
} as const satisfies Record<Provider, PrismaProvider>;
|
} as const satisfies Record<Provider, PrismaProvider>;
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ const prismaToApiProvider = {
|
|||||||
openai: "openai",
|
openai: "openai",
|
||||||
anthropic: "anthropic",
|
anthropic: "anthropic",
|
||||||
xai: "xai",
|
xai: "xai",
|
||||||
|
gemini: "gemini",
|
||||||
hermes_agent: "hermes-agent",
|
hermes_agent: "hermes-agent",
|
||||||
"hermes-agent": "hermes-agent",
|
"hermes-agent": "hermes-agent",
|
||||||
} as const satisfies Record<PrismaProvider | "hermes-agent", Provider>;
|
} as const satisfies Record<PrismaProvider | "hermes-agent", Provider>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import OpenAI from "openai";
|
|
||||||
import Anthropic from "@anthropic-ai/sdk";
|
import Anthropic from "@anthropic-ai/sdk";
|
||||||
|
import OpenAI from "openai";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
|
|
||||||
export function openaiClient() {
|
export function openaiClient() {
|
||||||
@@ -13,6 +13,14 @@ export function xaiClient() {
|
|||||||
return new OpenAI({ apiKey: env.XAI_API_KEY, baseURL: "https://api.x.ai/v1" });
|
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() {
|
export function isHermesAgentConfigured() {
|
||||||
return Boolean(env.HERMES_AGENT_API_KEY);
|
return Boolean(env.HERMES_AGENT_API_KEY);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
export type Provider = (typeof PROVIDERS)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { exaClient } from "./search/exa.js";
|
|||||||
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
|
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
|
||||||
import type { ChatAttachment } from "./llm/types.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 MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000;
|
||||||
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
|
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ test("provider backend registry selects chat protocol and managed-tool mode", ()
|
|||||||
managedTools: true,
|
managedTools: true,
|
||||||
enabledTools: ["web_search"],
|
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"]), {
|
assert.deepEqual(describeProviderChatBackend("hermes-agent", ["web_search"]), {
|
||||||
provider: "hermes-agent",
|
provider: "hermes-agent",
|
||||||
protocol: "chat-completions",
|
protocol: "chat-completions",
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { fromPrismaProvider, serializeProviderFields, toPrismaProvider } from ".
|
|||||||
test("Hermes Agent provider id maps between API and Prisma enum forms", () => {
|
test("Hermes Agent provider id maps between API and Prisma enum forms", () => {
|
||||||
assert.equal(toPrismaProvider("hermes-agent"), "hermes_agent");
|
assert.equal(toPrismaProvider("hermes-agent"), "hermes_agent");
|
||||||
assert.equal(fromPrismaProvider("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",
|
initiatedProvider: "hermes-agent",
|
||||||
lastUsedProvider: "xai",
|
lastUsedProvider: "gemini",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { Provider } from "./types.js";
|
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) {
|
function normalizeBaseUrl(value: string) {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
|
|||||||
+5
-1
@@ -42,12 +42,13 @@ type ToolLogMetadata = {
|
|||||||
resultPreview?: string | null;
|
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 PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
|
||||||
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
||||||
openai: ["gpt-4.1-mini"],
|
openai: ["gpt-4.1-mini"],
|
||||||
anthropic: ["claude-3-5-sonnet-latest"],
|
anthropic: ["claude-3-5-sonnet-latest"],
|
||||||
xai: ["grok-3-mini"],
|
xai: ["grok-3-mini"],
|
||||||
|
gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
||||||
"hermes-agent": ["hermes-agent"],
|
"hermes-agent": ["hermes-agent"],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
|||||||
openai: { models: [], loadedAt: null, error: null },
|
openai: { models: [], loadedAt: null, error: null },
|
||||||
anthropic: { models: [], loadedAt: null, error: null },
|
anthropic: { models: [], loadedAt: null, error: null },
|
||||||
xai: { models: [], loadedAt: null, error: null },
|
xai: { models: [], loadedAt: null, error: null },
|
||||||
|
gemini: { models: [], loadedAt: null, error: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
function escapeTags(value: string) {
|
function escapeTags(value: string) {
|
||||||
@@ -79,6 +81,7 @@ function getProviderLabel(provider: Provider | null | undefined) {
|
|||||||
if (provider === "openai") return "OpenAI";
|
if (provider === "openai") return "OpenAI";
|
||||||
if (provider === "anthropic") return "Anthropic";
|
if (provider === "anthropic") return "Anthropic";
|
||||||
if (provider === "xai") return "xAI";
|
if (provider === "xai") return "xAI";
|
||||||
|
if (provider === "gemini") return "Gemini";
|
||||||
if (provider === "hermes-agent") return "Hermes Agent";
|
if (provider === "hermes-agent") return "Hermes Agent";
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -266,6 +269,7 @@ async function main() {
|
|||||||
openai: null,
|
openai: null,
|
||||||
anthropic: null,
|
anthropic: null,
|
||||||
xai: null,
|
xai: null,
|
||||||
|
gemini: null,
|
||||||
"hermes-agent": null,
|
"hermes-agent": null,
|
||||||
};
|
};
|
||||||
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
|
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
|
export type Provider = "openai" | "anthropic" | "xai" | "gemini" | "hermes-agent";
|
||||||
|
|
||||||
export type ProviderModelInfo = {
|
export type ProviderModelInfo = {
|
||||||
models: string[];
|
models: string[];
|
||||||
|
|||||||
+10
-2
@@ -123,6 +123,7 @@ const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
|||||||
openai: ["gpt-4.1-mini"],
|
openai: ["gpt-4.1-mini"],
|
||||||
anthropic: ["claude-3-5-sonnet-latest"],
|
anthropic: ["claude-3-5-sonnet-latest"],
|
||||||
xai: ["grok-3-mini"],
|
xai: ["grok-3-mini"],
|
||||||
|
gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
||||||
"hermes-agent": ["hermes-agent"],
|
"hermes-agent": ["hermes-agent"],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,9 +131,10 @@ const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
|||||||
openai: { models: [], loadedAt: null, error: null },
|
openai: { models: [], loadedAt: null, error: null },
|
||||||
anthropic: { models: [], loadedAt: null, error: null },
|
anthropic: { models: [], loadedAt: null, error: null },
|
||||||
xai: { 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 ALL_PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
|
||||||
|
|
||||||
const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider";
|
const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider";
|
||||||
@@ -149,6 +151,7 @@ const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = {
|
|||||||
openai: null,
|
openai: null,
|
||||||
anthropic: null,
|
anthropic: null,
|
||||||
xai: null,
|
xai: null,
|
||||||
|
gemini: null,
|
||||||
"hermes-agent": null,
|
"hermes-agent": null,
|
||||||
};
|
};
|
||||||
const EMPTY_ACTIVE_RUNS: ActiveRunsState = {
|
const EMPTY_ACTIVE_RUNS: ActiveRunsState = {
|
||||||
@@ -345,6 +348,7 @@ function loadStoredModelPreferences() {
|
|||||||
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null,
|
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null,
|
||||||
anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.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,
|
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":
|
"hermes-agent":
|
||||||
typeof parsed["hermes-agent"] === "string" && parsed["hermes-agent"].trim() ? parsed["hermes-agent"].trim() : null,
|
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 {
|
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 {
|
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,
|
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null,
|
||||||
anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.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,
|
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":
|
"hermes-agent":
|
||||||
typeof parsed["hermes-agent"] === "string" && parsed["hermes-agent"].trim() ? parsed["hermes-agent"].trim() : null,
|
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 === "openai") return "OpenAI";
|
||||||
if (provider === "anthropic") return "Anthropic";
|
if (provider === "anthropic") return "Anthropic";
|
||||||
if (provider === "xai") return "xAI";
|
if (provider === "xai") return "xAI";
|
||||||
|
if (provider === "gemini") return "Gemini";
|
||||||
if (provider === "hermes-agent") return "Hermes Agent";
|
if (provider === "hermes-agent") return "Hermes Agent";
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -149,7 +149,7 @@ export type CompletionRequestMessage = {
|
|||||||
attachments?: ChatAttachment[];
|
attachments?: ChatAttachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
|
export type Provider = "openai" | "anthropic" | "xai" | "gemini" | "hermes-agent";
|
||||||
|
|
||||||
export type ProviderModelInfo = {
|
export type ProviderModelInfo = {
|
||||||
models: string[];
|
models: string[];
|
||||||
|
|||||||
Reference in New Issue
Block a user