Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
622659f6ca | ||
|
|
93ca8a76c3 | ||
|
|
69f50064a3 | ||
|
|
e7b81f24bd | ||
|
|
20a310a4b1 | ||
|
|
b6859706db | ||
|
|
ea148839d3 | ||
|
|
a0e410155d | ||
|
|
b5a5d21767 | ||
|
|
0ad96f4f99 | ||
|
|
b0c2b74c16 | ||
|
|
14d2d8cb28 | ||
|
|
03836bbc4c | ||
|
|
76ce62025a | ||
|
|
b15473d24e | ||
|
|
a512a65844 | ||
|
|
207b44f67f | ||
|
|
c5ccd212c9 | ||
|
|
ee990dde5d | ||
|
|
036743fe41 | ||
|
|
4e7ea7ab68 | ||
|
|
f232013e5a | ||
|
|
27c425f664 | ||
|
|
297b053a91 |
@@ -0,0 +1,44 @@
|
|||||||
|
name: TestFlight
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "release/ios/v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
testflight:
|
||||||
|
runs-on: macos-arm64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Ruby
|
||||||
|
uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
ruby-version: "3.1.7"
|
||||||
|
bundler-cache: true
|
||||||
|
working-directory: ios
|
||||||
|
|
||||||
|
- name: Install XcodeGen
|
||||||
|
run: command -v xcodegen >/dev/null 2>&1 || brew install xcodegen
|
||||||
|
|
||||||
|
- name: Upload to TestFlight
|
||||||
|
working-directory: ios
|
||||||
|
env:
|
||||||
|
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_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }}
|
||||||
|
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|
||||||
|
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
|
||||||
|
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
|
||||||
|
SYBIL_BUILD_NUMBER: ${{ github.run_number }}
|
||||||
|
FASTLANE_SKIP_UPDATE_CHECK: "1"
|
||||||
|
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: |
|
||||||
|
export PATH="/Users/runner/hostedtoolcache/Ruby/3.1.7/arm64/bin:${PATH}"
|
||||||
|
bundle exec fastlane ios beta
|
||||||
+2
-1
@@ -1,2 +1,3 @@
|
|||||||
.env
|
.env
|
||||||
|
ios/fastlane/README.md
|
||||||
|
ios/fastlane/report.xml
|
||||||
|
|||||||
@@ -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:-}
|
||||||
|
|||||||
+14
-9
@@ -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` 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": [
|
||||||
{
|
{
|
||||||
@@ -285,19 +287,23 @@ Behavior notes:
|
|||||||
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
|
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
|
||||||
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
||||||
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
||||||
|
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
|
||||||
- Server persists final assistant output and call metadata (`LlmCall`) in DB.
|
- Server persists final assistant output and call metadata (`LlmCall`) in DB.
|
||||||
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
|
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
|
||||||
- Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
|
- Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
|
||||||
- Images are forwarded inline to providers as multimodal image parts. Use PNG or JPEG for cross-provider compatibility.
|
- Images are forwarded inline to providers as multimodal image parts. Use PNG or JPEG for cross-provider compatibility.
|
||||||
- Text files are forwarded as explicit text blocks rather than provider-managed file references. Large text attachments should already be truncated client-side before submission.
|
- Text files are forwarded as explicit text blocks rather than provider-managed file references. Large text attachments should already be truncated client-side before submission.
|
||||||
- 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 `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` 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.
|
||||||
@@ -315,7 +321,6 @@ Behavior notes:
|
|||||||
- `CHAT_CODEX_EXEC_TIMEOUT_MS=600000` (optional)
|
- `CHAT_CODEX_EXEC_TIMEOUT_MS=600000` (optional)
|
||||||
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
|
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
|
||||||
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests emit an initiated SSE `tool_call` event before execution, then persist each completed or failed tool call as its terminal SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
|
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests emit an initiated SSE `tool_call` event before execution, then persist each completed or failed tool call as its terminal SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
|
||||||
- `anthropic` currently runs without server-managed tool calls.
|
|
||||||
|
|
||||||
## Searches
|
## Searches
|
||||||
|
|
||||||
@@ -416,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"]
|
||||||
@@ -468,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": [
|
||||||
{
|
{
|
||||||
@@ -64,6 +64,7 @@ Notes:
|
|||||||
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
|
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
|
||||||
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
||||||
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
||||||
|
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
|
||||||
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
||||||
|
|
||||||
Persisted chat streams with a `chatId` are backend-owned active runs:
|
Persisted chat streams with a `chatId` are backend-owned active runs:
|
||||||
@@ -171,19 +172,22 @@ Terminal tool-call event:
|
|||||||
## Provider Streaming Behavior
|
## Provider Streaming Behavior
|
||||||
|
|
||||||
- `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.
|
||||||
- `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`. 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.
|
||||||
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. This only affects chat-mode tool calls, not search-mode endpoints.
|
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. This only affects chat-mode tool calls, not search-mode endpoints.
|
||||||
- `codex_exec` is available only when `CHAT_CODEX_TOOL_ENABLED=true`. It SSHes to `CHAT_CODEX_REMOTE_HOST`, creates/uses `CHAT_CODEX_REMOTE_WORKDIR`, and runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` there with SSH stdin closed. Prefer `CHAT_CODEX_SSH_KEY_PATH` with a read-only mounted private key; `CHAT_CODEX_SSH_PRIVATE_KEY_B64` is also supported.
|
- `codex_exec` is available only when `CHAT_CODEX_TOOL_ENABLED=true`. It SSHes to `CHAT_CODEX_REMOTE_HOST`, creates/uses `CHAT_CODEX_REMOTE_WORKDIR`, and runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` there with SSH stdin closed. Prefer `CHAT_CODEX_SSH_KEY_PATH` with a read-only mounted private key; `CHAT_CODEX_SSH_PRIVATE_KEY_B64` is also supported.
|
||||||
- `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`/`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.
|
||||||
|
|||||||
+8
-4
@@ -1,14 +1,18 @@
|
|||||||
FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2
|
FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2
|
||||||
FASTLANE_TEAM_ID=DQQH5H6GBD
|
FASTLANE_TEAM_ID=DQQH5H6GBD
|
||||||
FASTLANE_USER=you@example.com
|
|
||||||
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
|
|
||||||
FASTLANE_SKIP_UPDATE_CHECK=1
|
FASTLANE_SKIP_UPDATE_CHECK=1
|
||||||
FASTLANE_HIDE_CHANGELOG=1
|
FASTLANE_HIDE_CHANGELOG=1
|
||||||
SYBIL_APP_STORE_APPLE_ID=6759442828
|
SYBIL_APP_STORE_APPLE_ID=6759442828
|
||||||
SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2
|
SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2
|
||||||
|
SYBIL_PROVISIONING_PROFILE_SPECIFIER=Sybil AppStore CI
|
||||||
|
SYBIL_PROVISIONING_PROFILE_UUID=
|
||||||
|
SYBIL_CODE_SIGN_IDENTITY=Apple Distribution: James Magahern (DQQH5H6GBD)
|
||||||
|
SYBIL_XCODE_CODE_SIGN_IDENTITY=6B74B268C4761720FB2051D01D8BB3E47B55D9F5
|
||||||
|
SYBIL_EXPORT_SIGNING_CERTIFICATE=Apple Distribution
|
||||||
|
SYBIL_SIGNING_CERTIFICATE_ID=
|
||||||
|
SYBIL_SIGNING_KEYCHAIN=
|
||||||
|
|
||||||
# Optional App Store Connect API key settings for non-interactive upload and
|
# App Store Connect API key settings for TestFlight upload and signing setup.
|
||||||
# TestFlight build-number lookup.
|
|
||||||
APP_STORE_CONNECT_API_KEY_ID=
|
APP_STORE_CONNECT_API_KEY_ID=
|
||||||
APP_STORE_CONNECT_API_ISSUER_ID=
|
APP_STORE_CONNECT_API_ISSUER_ID=
|
||||||
APP_STORE_CONNECT_API_KEY_PATH=
|
APP_STORE_CONNECT_API_KEY_PATH=
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
|||||||
- To choose a screenshot path, run `just screenshot path=build/name.png`.
|
- To choose a screenshot path, run `just screenshot path=build/name.png`.
|
||||||
- The underlying screenshot command is `xcrun simctl io booted screenshot <path>` and requires a booted simulator.
|
- The underlying screenshot command is `xcrun simctl io booted screenshot <path>` and requires a booted simulator.
|
||||||
|
|
||||||
|
## Release Workflow
|
||||||
|
- iOS release tags use the annotated tag namespace `release/ios/vX.Y.Z`; increment from the latest existing `release/ios/v*` tag.
|
||||||
|
- Tag message convention is `ios: X.Y.Z`, for example `git tag -a release/ios/v1.13.5 -m "ios: 1.13.5"`.
|
||||||
|
- Push the release commit and tag together with `git push origin <branch> release/ios/vX.Y.Z`.
|
||||||
|
- Fastlane derives the marketing version from the release tag and stamps `ios/Apps/Sybil/project.yml` during CI, so do not manually bump `MARKETING_VERSION` for normal tagged releases unless explicitly requested.
|
||||||
|
|
||||||
## App Structure
|
## App Structure
|
||||||
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
|
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
|
||||||
- Shared iOS app code lives in Swift package:
|
- Shared iOS app code lives in Swift package:
|
||||||
@@ -51,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`
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ targets:
|
|||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
MARKETING_VERSION: "1.10"
|
MARKETING_VERSION: "1.13.2"
|
||||||
CURRENT_PROJECT_VERSION: 11
|
CURRENT_PROJECT_VERSION: 11
|
||||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||||
@@ -32,6 +32,12 @@ targets:
|
|||||||
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
|
||||||
|
configs:
|
||||||
|
Release:
|
||||||
|
CODE_SIGN_STYLE: Manual
|
||||||
|
CODE_SIGN_IDENTITY: Apple Distribution
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]": Apple Distribution
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER: Sybil AppStore CI
|
||||||
|
|
||||||
schemes:
|
schemes:
|
||||||
Sybil:
|
Sybil:
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
source "https://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
gem "fastlane", "~> 2.227"
|
gem "fastlane"
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
GEM
|
||||||
|
remote: https://rubygems.org/
|
||||||
|
specs:
|
||||||
|
CFPropertyList (3.0.9)
|
||||||
|
abbrev (0.1.2)
|
||||||
|
addressable (2.9.0)
|
||||||
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
|
artifactory (3.0.17)
|
||||||
|
atomos (0.1.3)
|
||||||
|
aws-eventstream (1.3.2)
|
||||||
|
aws-partitions (1.1109.0)
|
||||||
|
aws-sdk-core (3.224.1)
|
||||||
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
|
aws-sigv4 (~> 1.9)
|
||||||
|
base64
|
||||||
|
jmespath (~> 1, >= 1.6.1)
|
||||||
|
logger
|
||||||
|
aws-sdk-kms (1.101.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.216.0)
|
||||||
|
aws-sigv4 (~> 1.5)
|
||||||
|
aws-sdk-s3 (1.188.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.224.1)
|
||||||
|
aws-sdk-kms (~> 1)
|
||||||
|
aws-sigv4 (~> 1.5)
|
||||||
|
aws-sigv4 (1.11.0)
|
||||||
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
|
babosa (1.0.4)
|
||||||
|
base64 (0.2.0)
|
||||||
|
claide (1.1.0)
|
||||||
|
colored (1.2)
|
||||||
|
colored2 (3.1.2)
|
||||||
|
commander (4.6.0)
|
||||||
|
highline (~> 2.0.0)
|
||||||
|
csv (3.3.5)
|
||||||
|
declarative (0.0.20)
|
||||||
|
digest-crc (0.7.0)
|
||||||
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
|
domain_name (0.5.20190701)
|
||||||
|
unf (>= 0.0.5, < 1.0.0)
|
||||||
|
dotenv (2.8.1)
|
||||||
|
emoji_regex (3.2.3)
|
||||||
|
excon (0.109.0)
|
||||||
|
faraday (1.10.6)
|
||||||
|
faraday-em_http (~> 1.0)
|
||||||
|
faraday-em_synchrony (~> 1.0)
|
||||||
|
faraday-excon (~> 1.1)
|
||||||
|
faraday-httpclient (~> 1.0)
|
||||||
|
faraday-multipart (~> 1.0)
|
||||||
|
faraday-net_http (~> 1.0)
|
||||||
|
faraday-net_http_persistent (~> 1.0)
|
||||||
|
faraday-patron (~> 1.0)
|
||||||
|
faraday-rack (~> 1.0)
|
||||||
|
faraday-retry (~> 1.0)
|
||||||
|
ruby2_keywords (>= 0.0.4)
|
||||||
|
faraday-cookie_jar (0.0.8)
|
||||||
|
faraday (>= 0.8.0)
|
||||||
|
http-cookie (>= 1.0.0)
|
||||||
|
faraday-em_http (1.0.0)
|
||||||
|
faraday-em_synchrony (1.0.1)
|
||||||
|
faraday-excon (1.1.0)
|
||||||
|
faraday-httpclient (1.0.1)
|
||||||
|
faraday-multipart (1.2.0)
|
||||||
|
multipart-post (~> 2.0)
|
||||||
|
faraday-net_http (1.0.2)
|
||||||
|
faraday-net_http_persistent (1.2.0)
|
||||||
|
faraday-patron (1.0.0)
|
||||||
|
faraday-rack (1.0.0)
|
||||||
|
faraday-retry (1.0.4)
|
||||||
|
faraday_middleware (1.2.1)
|
||||||
|
faraday (~> 1.0)
|
||||||
|
fastimage (2.4.1)
|
||||||
|
fastlane (2.230.0)
|
||||||
|
CFPropertyList (>= 2.3, < 4.0.0)
|
||||||
|
abbrev (~> 0.1.2)
|
||||||
|
addressable (>= 2.8, < 3.0.0)
|
||||||
|
artifactory (~> 3.0)
|
||||||
|
aws-sdk-s3 (~> 1.0)
|
||||||
|
babosa (>= 1.0.3, < 2.0.0)
|
||||||
|
base64 (~> 0.2.0)
|
||||||
|
bundler (>= 1.12.0, < 3.0.0)
|
||||||
|
colored (~> 1.2)
|
||||||
|
commander (~> 4.6)
|
||||||
|
csv (~> 3.3)
|
||||||
|
dotenv (>= 2.1.1, < 3.0.0)
|
||||||
|
emoji_regex (>= 0.1, < 4.0)
|
||||||
|
excon (>= 0.71.0, < 1.0.0)
|
||||||
|
faraday (~> 1.0)
|
||||||
|
faraday-cookie_jar (~> 0.0.6)
|
||||||
|
faraday_middleware (~> 1.0)
|
||||||
|
fastimage (>= 2.1.0, < 3.0.0)
|
||||||
|
fastlane-sirp (>= 1.0.0)
|
||||||
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
|
google-apis-androidpublisher_v3 (~> 0.3)
|
||||||
|
google-apis-playcustomapp_v1 (~> 0.1)
|
||||||
|
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||||
|
google-cloud-storage (~> 1.31)
|
||||||
|
highline (~> 2.0)
|
||||||
|
http-cookie (~> 1.0.5)
|
||||||
|
json (< 3.0.0)
|
||||||
|
jwt (>= 2.1.0, < 3)
|
||||||
|
logger (>= 1.6, < 2.0)
|
||||||
|
mini_magick (>= 4.9.4, < 5.0.0)
|
||||||
|
multipart-post (>= 2.0.0, < 3.0.0)
|
||||||
|
mutex_m (~> 0.3.0)
|
||||||
|
naturally (~> 2.2)
|
||||||
|
nkf (~> 0.2.0)
|
||||||
|
optparse (>= 0.1.1, < 1.0.0)
|
||||||
|
plist (>= 3.1.0, < 4.0.0)
|
||||||
|
rubyzip (>= 2.0.0, < 3.0.0)
|
||||||
|
security (= 0.1.5)
|
||||||
|
simctl (~> 1.6.3)
|
||||||
|
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||||
|
terminal-table (~> 3)
|
||||||
|
tty-screen (>= 0.6.3, < 1.0.0)
|
||||||
|
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||||
|
word_wrap (~> 1.0.0)
|
||||||
|
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||||
|
xcpretty (~> 0.4.1)
|
||||||
|
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||||
|
fastlane-sirp (1.1.0)
|
||||||
|
gh_inspector (1.1.3)
|
||||||
|
google-apis-androidpublisher_v3 (0.54.0)
|
||||||
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
|
google-apis-core (0.11.3)
|
||||||
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
|
httpclient (>= 2.8.1, < 3.a)
|
||||||
|
mini_mime (~> 1.0)
|
||||||
|
representable (~> 3.0)
|
||||||
|
retriable (>= 2.0, < 4.a)
|
||||||
|
rexml
|
||||||
|
google-apis-iamcredentials_v1 (0.17.0)
|
||||||
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
|
google-apis-playcustomapp_v1 (0.13.0)
|
||||||
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
|
google-apis-storage_v1 (0.29.0)
|
||||||
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
|
google-cloud-core (1.6.1)
|
||||||
|
google-cloud-env (>= 1.0, < 3.a)
|
||||||
|
google-cloud-errors (~> 1.0)
|
||||||
|
google-cloud-env (1.6.0)
|
||||||
|
faraday (>= 0.17.3, < 3.0)
|
||||||
|
google-cloud-errors (1.3.1)
|
||||||
|
google-cloud-storage (1.45.0)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
digest-crc (~> 0.4)
|
||||||
|
google-apis-iamcredentials_v1 (~> 0.1)
|
||||||
|
google-apis-storage_v1 (~> 0.29.0)
|
||||||
|
google-cloud-core (~> 1.6)
|
||||||
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
|
mini_mime (~> 1.0)
|
||||||
|
googleauth (1.8.1)
|
||||||
|
faraday (>= 0.17.3, < 3.a)
|
||||||
|
jwt (>= 1.4, < 3.0)
|
||||||
|
multi_json (~> 1.11)
|
||||||
|
os (>= 0.9, < 2.0)
|
||||||
|
signet (>= 0.16, < 2.a)
|
||||||
|
highline (2.0.3)
|
||||||
|
http-cookie (1.0.8)
|
||||||
|
domain_name (~> 0.5)
|
||||||
|
httpclient (2.9.0)
|
||||||
|
mutex_m
|
||||||
|
jmespath (1.6.2)
|
||||||
|
json (2.7.6)
|
||||||
|
jwt (2.10.3)
|
||||||
|
base64
|
||||||
|
logger (1.7.0)
|
||||||
|
mini_magick (4.13.2)
|
||||||
|
mini_mime (1.1.5)
|
||||||
|
multi_json (1.15.0)
|
||||||
|
multipart-post (2.4.1)
|
||||||
|
mutex_m (0.3.0)
|
||||||
|
nanaimo (0.4.0)
|
||||||
|
naturally (2.3.0)
|
||||||
|
nkf (0.2.0)
|
||||||
|
optparse (0.8.1)
|
||||||
|
os (1.1.4)
|
||||||
|
plist (3.7.2)
|
||||||
|
public_suffix (5.1.1)
|
||||||
|
rake (13.4.2)
|
||||||
|
representable (3.2.0)
|
||||||
|
declarative (< 0.1.0)
|
||||||
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
|
uber (< 0.2.0)
|
||||||
|
retriable (3.8.0)
|
||||||
|
rexml (3.4.4)
|
||||||
|
rouge (3.28.0)
|
||||||
|
ruby2_keywords (0.0.5)
|
||||||
|
rubyzip (2.4.1)
|
||||||
|
security (0.1.5)
|
||||||
|
signet (0.18.0)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
faraday (>= 0.17.5, < 3.a)
|
||||||
|
jwt (>= 1.5, < 3.0)
|
||||||
|
multi_json (~> 1.10)
|
||||||
|
simctl (1.6.10)
|
||||||
|
CFPropertyList
|
||||||
|
naturally
|
||||||
|
terminal-notifier (2.0.0)
|
||||||
|
terminal-table (3.0.2)
|
||||||
|
unicode-display_width (>= 1.1.1, < 3)
|
||||||
|
trailblazer-option (0.1.2)
|
||||||
|
tty-cursor (0.7.1)
|
||||||
|
tty-screen (0.8.2)
|
||||||
|
tty-spinner (0.9.3)
|
||||||
|
tty-cursor (~> 0.7)
|
||||||
|
uber (0.1.0)
|
||||||
|
unf (0.2.0)
|
||||||
|
unicode-display_width (2.6.0)
|
||||||
|
word_wrap (1.0.0)
|
||||||
|
xcodeproj (1.27.0)
|
||||||
|
CFPropertyList (>= 2.3.3, < 4.0)
|
||||||
|
atomos (~> 0.1.3)
|
||||||
|
claide (>= 1.0.2, < 2.0)
|
||||||
|
colored2 (~> 3.1)
|
||||||
|
nanaimo (~> 0.4.0)
|
||||||
|
rexml (>= 3.3.6, < 4.0)
|
||||||
|
xcpretty (0.4.1)
|
||||||
|
rouge (~> 3.28.0)
|
||||||
|
xcpretty-travis-formatter (1.0.1)
|
||||||
|
xcpretty (~> 0.2, >= 0.0.7)
|
||||||
|
|
||||||
|
PLATFORMS
|
||||||
|
ruby
|
||||||
|
|
||||||
|
DEPENDENCIES
|
||||||
|
fastlane
|
||||||
|
|
||||||
|
BUNDLED WITH
|
||||||
|
2.5.23
|
||||||
@@ -9,10 +9,23 @@ struct SybilChatTranscriptView: View {
|
|||||||
var bottomContentInset: CGFloat = 0
|
var bottomContentInset: CGFloat = 0
|
||||||
var bottomPinRequestID: Int = 0
|
var bottomPinRequestID: Int = 0
|
||||||
|
|
||||||
|
@State private var hasTrackedToolCallMessages = false
|
||||||
|
@State private var knownToolCallMessageIDs: Set<String> = []
|
||||||
|
|
||||||
private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
|
private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
|
||||||
private var renderItems: [TranscriptRenderItem] {
|
private var renderItems: [TranscriptRenderItem] {
|
||||||
buildTranscriptRenderItems(from: messages)
|
buildTranscriptRenderItems(from: messages)
|
||||||
}
|
}
|
||||||
|
private var toolCallMessageIDs: Set<String> {
|
||||||
|
Set(messages.compactMap { $0.toolCallMetadata == nil ? nil : $0.id })
|
||||||
|
}
|
||||||
|
private var enteringToolCallMessageIDs: Set<String> {
|
||||||
|
guard hasTrackedToolCallMessages else { return [] }
|
||||||
|
return toolCallMessageIDs.subtracting(knownToolCallMessageIDs)
|
||||||
|
}
|
||||||
|
private var toolCallMessageIDSignature: String {
|
||||||
|
toolCallMessageIDs.sorted().joined(separator: "|")
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
@@ -31,7 +44,11 @@ struct SybilChatTranscriptView: View {
|
|||||||
MessageBubble(message: message, isSending: isSending)
|
MessageBubble(message: message, isSending: isSending)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
case let .toolGroup(id, messages):
|
case let .toolGroup(id, messages):
|
||||||
ToolCallStackView(groupID: id, messages: messages)
|
ToolCallStackView(
|
||||||
|
groupID: id,
|
||||||
|
messages: messages,
|
||||||
|
entryAnimationIDs: enteringToolCallMessageIDs
|
||||||
|
)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.id(id)
|
.id(id)
|
||||||
}
|
}
|
||||||
@@ -41,14 +58,18 @@ struct SybilChatTranscriptView: View {
|
|||||||
.frame(height: 18 + bottomContentInset)
|
.frame(height: 18 + bottomContentInset)
|
||||||
.id(bottomAnchorID)
|
.id(bottomAnchorID)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.top, 18 + topContentInset)
|
.padding(.top, 18 + topContentInset)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
scrollToBottom(with: proxy, animated: false)
|
syncKnownToolCallMessageIDs()
|
||||||
|
}
|
||||||
|
.onChange(of: toolCallMessageIDSignature) { _, _ in
|
||||||
|
syncKnownToolCallMessageIDs()
|
||||||
}
|
}
|
||||||
.onChange(of: bottomPinRequestID) { _, _ in
|
.onChange(of: bottomPinRequestID) { _, _ in
|
||||||
scrollToBottom(with: proxy, animated: true)
|
scrollToBottom(with: proxy, animated: true)
|
||||||
@@ -67,6 +88,12 @@ struct SybilChatTranscriptView: View {
|
|||||||
action()
|
action()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func syncKnownToolCallMessageIDs() {
|
||||||
|
guard !toolCallMessageIDs.isEmpty else { return }
|
||||||
|
knownToolCallMessageIDs.formUnion(toolCallMessageIDs)
|
||||||
|
hasTrackedToolCallMessages = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TranscriptRenderItem: Identifiable {
|
enum TranscriptRenderItem: Identifiable {
|
||||||
@@ -216,6 +243,7 @@ private struct ToolCallStackView: View {
|
|||||||
|
|
||||||
var groupID: String
|
var groupID: String
|
||||||
var messages: [Message]
|
var messages: [Message]
|
||||||
|
var entryAnimationIDs: Set<String>
|
||||||
|
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
@State private var isExpanded = false
|
@State private var isExpanded = false
|
||||||
@@ -262,8 +290,14 @@ private struct ToolCallStackView: View {
|
|||||||
let layout = layout(for: index)
|
let layout = layout(for: index)
|
||||||
let depth = messages.count - index - 1
|
let depth = messages.count - index - 1
|
||||||
let isHidden = !isExpanded && depth >= visibleCollapsedLimit
|
let isHidden = !isExpanded && depth >= visibleCollapsedLimit
|
||||||
|
let shouldAnimateEntry = entryAnimationIDs.contains(message.id) && !isHidden
|
||||||
|
|
||||||
ToolCallStackCard(message: message, cardHeight: cardHeight, compactLayout: true)
|
ToolCallStackCard(
|
||||||
|
message: message,
|
||||||
|
cardHeight: cardHeight,
|
||||||
|
compactLayout: true,
|
||||||
|
animateEntry: shouldAnimateEntry
|
||||||
|
)
|
||||||
.frame(width: cardWidth, height: cardHeight, alignment: .topLeading)
|
.frame(width: cardWidth, height: cardHeight, alignment: .topLeading)
|
||||||
.scaleEffect(layout.scale, anchor: .topLeading)
|
.scaleEffect(layout.scale, anchor: .topLeading)
|
||||||
.opacity(layout.opacity)
|
.opacity(layout.opacity)
|
||||||
@@ -362,10 +396,16 @@ private struct ToolCallStackCard: View {
|
|||||||
var message: Message
|
var message: Message
|
||||||
var cardHeight: CGFloat
|
var cardHeight: CGFloat
|
||||||
var compactLayout: Bool
|
var compactLayout: Bool
|
||||||
|
var animateEntry: Bool
|
||||||
|
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
@State private var entryAnimationArmed = false
|
||||||
@State private var didEnter = false
|
@State private var didEnter = false
|
||||||
|
|
||||||
|
private var isPreparingEntry: Bool {
|
||||||
|
(animateEntry || entryAnimationArmed) && !didEnter
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if let metadata = message.toolCallMetadata {
|
if let metadata = message.toolCallMetadata {
|
||||||
@@ -378,12 +418,17 @@ private struct ToolCallStackCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: cardHeight, alignment: .top)
|
.frame(height: cardHeight, alignment: .top)
|
||||||
.scaleEffect(didEnter ? 1 : 1.025, anchor: .topLeading)
|
.scaleEffect(isPreparingEntry ? 1.025 : 1, anchor: .topLeading)
|
||||||
.offset(y: didEnter ? 0 : -8)
|
.offset(y: isPreparingEntry ? -8 : 0)
|
||||||
.rotation3DEffect(.degrees(didEnter ? 0 : 3), axis: (x: 1, y: 0, z: 0), anchor: .top)
|
.rotation3DEffect(.degrees(isPreparingEntry ? 3 : 0), axis: (x: 1, y: 0, z: 0), anchor: .top)
|
||||||
.opacity(didEnter ? 1 : 0.72)
|
.opacity(isPreparingEntry ? 0.72 : 1)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
guard !didEnter else { return }
|
guard !didEnter, !entryAnimationArmed else { return }
|
||||||
|
guard animateEntry else {
|
||||||
|
didEnter = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entryAnimationArmed = true
|
||||||
if reduceMotion {
|
if reduceMotion {
|
||||||
didEnter = true
|
didEnter = true
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ struct SybilSearchResultsView: View {
|
|||||||
.foregroundStyle(SybilTheme.danger)
|
.foregroundStyle(SybilTheme.danger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.top, 20 + topContentInset)
|
.padding(.top, 20 + topContentInset)
|
||||||
.padding(.bottom, 20 + bottomContentInset)
|
.padding(.bottom, 20 + bottomContentInset)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ extension Font {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SybilLayout {
|
||||||
|
static let webContentMaxWidth: CGFloat = 896
|
||||||
|
}
|
||||||
|
|
||||||
enum SybilTheme {
|
enum SybilTheme {
|
||||||
static let background = Color(red: 0.02, green: 0.02, blue: 0.05)
|
static let background = Color(red: 0.02, green: 0.02, blue: 0.05)
|
||||||
static let surface = Color(red: 0.05, green: 0.04, blue: 0.10)
|
static let surface = Color(red: 0.05, green: 0.04, blue: 0.10)
|
||||||
@@ -179,8 +183,8 @@ enum SybilTheme {
|
|||||||
static var toolCallGradient: LinearGradient {
|
static var toolCallGradient: LinearGradient {
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
Color(red: 0.01, green: 0.15, blue: 0.17).opacity(0.70),
|
Color(red: 0.01, green: 0.15, blue: 0.17),
|
||||||
Color(red: 0.03, green: 0.09, blue: 0.15).opacity(0.78)
|
Color(red: 0.03, green: 0.09, blue: 0.15)
|
||||||
],
|
],
|
||||||
startPoint: .leading,
|
startPoint: .leading,
|
||||||
endPoint: .trailing
|
endPoint: .trailing
|
||||||
@@ -190,8 +194,8 @@ enum SybilTheme {
|
|||||||
static var runningToolCallGradient: LinearGradient {
|
static var runningToolCallGradient: LinearGradient {
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
Color(red: 0.30, green: 0.19, blue: 0.04).opacity(0.72),
|
Color(red: 0.30, green: 0.19, blue: 0.04),
|
||||||
Color(red: 0.09, green: 0.05, blue: 0.17).opacity(0.78)
|
Color(red: 0.09, green: 0.05, blue: 0.17)
|
||||||
],
|
],
|
||||||
startPoint: .leading,
|
startPoint: .leading,
|
||||||
endPoint: .trailing
|
endPoint: .trailing
|
||||||
@@ -201,8 +205,8 @@ enum SybilTheme {
|
|||||||
static var failedToolCallGradient: LinearGradient {
|
static var failedToolCallGradient: LinearGradient {
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
danger.opacity(0.18),
|
Color(red: 0.27, green: 0.04, blue: 0.10),
|
||||||
Color(red: 0.15, green: 0.03, blue: 0.07).opacity(0.72)
|
Color(red: 0.15, green: 0.03, blue: 0.07)
|
||||||
],
|
],
|
||||||
startPoint: .leading,
|
startPoint: .leading,
|
||||||
endPoint: .trailing
|
endPoint: .trailing
|
||||||
|
|||||||
@@ -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
|
||||||
|
if isSelectingDifferentChat {
|
||||||
requestChatBottomPin()
|
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
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
require "dotenv"
|
|
||||||
|
|
||||||
Dotenv.load(File.expand_path("../.env", __dir__))
|
|
||||||
|
|
||||||
app_identifier(ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2"))
|
|
||||||
team_id(ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD"))
|
|
||||||
|
|
||||||
apple_id(ENV["FASTLANE_USER"]) if ENV["FASTLANE_USER"].to_s.strip.length.positive?
|
|
||||||
itc_team_id(ENV["FASTLANE_ITC_TEAM_ID"]) if ENV["FASTLANE_ITC_TEAM_ID"].to_s.strip.length.positive?
|
|
||||||
+122
-135
@@ -1,177 +1,164 @@
|
|||||||
require "dotenv"
|
|
||||||
require "open3"
|
|
||||||
require "shellwords"
|
require "shellwords"
|
||||||
require "yaml"
|
|
||||||
|
|
||||||
Dotenv.load(File.expand_path("../.env", __dir__))
|
|
||||||
|
|
||||||
default_platform(:ios)
|
default_platform(:ios)
|
||||||
|
|
||||||
APP_IDENTIFIER = ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2")
|
APP_IDENTIFIER = "net.buzzert.sybil2"
|
||||||
TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD")
|
SCHEME = "Sybil"
|
||||||
APP_STORE_APPLE_ID = ENV.fetch("SYBIL_APP_STORE_APPLE_ID", "6759442828")
|
TEAM_ID = "DQQH5H6GBD"
|
||||||
PROVIDER_PUBLIC_ID = ENV.fetch("SYBIL_PROVIDER_PUBLIC_ID", "c043d167-ad88-4036-84ea-76c223f1b1b2")
|
PROFILE_NAME = "Sybil AppStore CI"
|
||||||
|
CI_KEYCHAIN_NAME = "sybil_ci_keychain"
|
||||||
|
CI_KEYCHAIN_PASSWORD = "sybil-ci-keychain-password"
|
||||||
|
CI_KEYCHAIN_DB_PATH = File.expand_path("~/Library/Keychains/#{CI_KEYCHAIN_NAME}-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")
|
||||||
APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
|
APP_PROJECT_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
|
||||||
SCHEME = "Sybil"
|
|
||||||
TARGET = "SybilApp"
|
|
||||||
|
|
||||||
def present?(value)
|
def present?(value)
|
||||||
!value.to_s.strip.empty?
|
!value.to_s.strip.empty?
|
||||||
end
|
end
|
||||||
|
|
||||||
def capture(command)
|
def ci?
|
||||||
stdout, stderr, status = Open3.capture3(command)
|
present?(ENV["CI"])
|
||||||
return stdout.strip if status.success?
|
|
||||||
|
|
||||||
UI.user_error!("Command failed: #{command}\n#{stderr.strip}")
|
|
||||||
end
|
|
||||||
|
|
||||||
def app_project_settings
|
|
||||||
YAML.safe_load(File.read(APP_SPEC)).fetch("targets").fetch(TARGET).fetch("settings").fetch("base")
|
|
||||||
end
|
|
||||||
|
|
||||||
def local_marketing_version
|
|
||||||
app_project_settings.fetch("MARKETING_VERSION").to_s
|
|
||||||
end
|
|
||||||
|
|
||||||
def local_build_number
|
|
||||||
app_project_settings.fetch("CURRENT_PROJECT_VERSION").to_i
|
|
||||||
end
|
|
||||||
|
|
||||||
def normalize_version_tag(tag)
|
|
||||||
version = tag.to_s.strip.sub(/\Av/, "")
|
|
||||||
unless version.match?(/\A\d+\.\d+(\.\d+)?\z/)
|
|
||||||
UI.user_error!("Release tag #{tag.inspect} must look like v1.10 or v1.10.0")
|
|
||||||
end
|
|
||||||
version
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def release_version
|
def release_version
|
||||||
tag = ENV["SYBIL_VERSION_TAG"]
|
tag = ENV["SYBIL_VERSION_TAG"]
|
||||||
tag = capture("git describe --tags --abbrev=0") unless present?(tag)
|
tag = ENV["GITHUB_REF_NAME"] if !present?(tag)
|
||||||
normalize_version_tag(tag)
|
tag = ENV["GITHUB_REF"].to_s.sub(%r{\Arefs/tags/}, "") if !present?(tag)
|
||||||
end
|
tag = sh("git describe --tags --abbrev=0").strip if !present?(tag)
|
||||||
|
match = tag.to_s.match(%r{\Arelease/ios/v(\d+\.\d+\.\d+)\z})
|
||||||
|
|
||||||
def xcode_build_setting(key, value)
|
unless match
|
||||||
"#{key}=#{value.to_s.shellescape}"
|
UI.user_error!("Release tag must look like release/ios/v1.2.3; got #{tag.inspect}")
|
||||||
end
|
|
||||||
|
|
||||||
def app_store_connect_key_options
|
|
||||||
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
|
|
||||||
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
|
|
||||||
return nil unless present?(key_id) && present?(issuer_id)
|
|
||||||
|
|
||||||
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
|
|
||||||
key_content = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
|
|
||||||
if present?(key_path)
|
|
||||||
{
|
|
||||||
key_id: key_id,
|
|
||||||
issuer_id: issuer_id,
|
|
||||||
key_filepath: key_path
|
|
||||||
}
|
|
||||||
elsif present?(key_content)
|
|
||||||
{
|
|
||||||
key_id: key_id,
|
|
||||||
issuer_id: issuer_id,
|
|
||||||
key_content: key_content,
|
|
||||||
is_key_content_base64: ENV["APP_STORE_CONNECT_API_KEY_CONTENT_BASE64"].to_s == "true"
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
match[1]
|
||||||
|
end
|
||||||
|
|
||||||
|
# App Store Connect requires CFBundleVersion to be unique and strictly
|
||||||
|
# increasing app-wide (not just per marketing version), so we derive it from
|
||||||
|
# the monotonic CI run number rather than querying TestFlight (that query can
|
||||||
|
# lag behind builds still processing and hand back a colliding value).
|
||||||
|
def build_number
|
||||||
|
value = present?(ENV["SYBIL_BUILD_NUMBER"]) ? ENV["SYBIL_BUILD_NUMBER"] : ENV["GITHUB_RUN_NUMBER"]
|
||||||
|
|
||||||
|
unless value.to_s.match?(/\A\d+\z/)
|
||||||
|
UI.user_error!("Build number must come from SYBIL_BUILD_NUMBER/GITHUB_RUN_NUMBER; got #{value.inspect}")
|
||||||
|
end
|
||||||
|
|
||||||
|
value.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def stamp_marketing_version(version)
|
||||||
|
contents = File.read(APP_PROJECT_SPEC)
|
||||||
|
updated = contents.sub(/^(\s*MARKETING_VERSION:\s*).*/, "\\1\"#{version}\"")
|
||||||
|
|
||||||
|
if updated == contents
|
||||||
|
UI.user_error!("Could not find MARKETING_VERSION in #{APP_PROJECT_SPEC}")
|
||||||
|
end
|
||||||
|
|
||||||
|
File.write(APP_PROJECT_SPEC, updated)
|
||||||
end
|
end
|
||||||
|
|
||||||
platform :ios do
|
platform :ios do
|
||||||
desc "Show the version Fastlane will stamp into the next TestFlight archive"
|
private_lane :app_store_api_key do
|
||||||
lane :version do
|
app_store_connect_api_key(
|
||||||
UI.message("Git tag version: #{release_version}")
|
key_id: ENV.fetch("APP_STORE_CONNECT_KEY_ID"),
|
||||||
UI.message("Checked-in app version: #{local_marketing_version}")
|
issuer_id: ENV.fetch("APP_STORE_CONNECT_ISSUER_ID"),
|
||||||
UI.message("Checked-in build number: #{local_build_number}")
|
key_content: ENV.fetch("APP_STORE_CONNECT_KEY_CONTENT"),
|
||||||
|
is_key_content_base64: true
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
desc "Build Sybil and upload it to TestFlight"
|
# CI signs headlessly, so match needs a fresh unlocked keychain to import
|
||||||
lane :beta do
|
# into. codesign resolves identities through the user keychain *search list*
|
||||||
version = release_version
|
# (first match wins; the --keychain flag does not restrict the lookup), and
|
||||||
build_number = ENV["SYBIL_BUILD_NUMBER"].to_s
|
# other projects' keychains on this runner hold the same identity but are
|
||||||
api_key = nil
|
# 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
|
||||||
|
next unless ci?
|
||||||
|
|
||||||
if app_store_connect_key_options
|
delete_keychain(name: CI_KEYCHAIN_NAME) if File.file?(CI_KEYCHAIN_DB_PATH)
|
||||||
api_key = app_store_connect_api_key(app_store_connect_key_options)
|
create_keychain(
|
||||||
|
name: CI_KEYCHAIN_NAME,
|
||||||
|
password: CI_KEYCHAIN_PASSWORD,
|
||||||
|
unlock: true,
|
||||||
|
timeout: 3600,
|
||||||
|
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_PASSWORD"] = CI_KEYCHAIN_PASSWORD
|
||||||
end
|
end
|
||||||
|
|
||||||
unless present?(build_number)
|
private_lane :sync_signing do |options|
|
||||||
build_number = (local_build_number + 1).to_s
|
match(
|
||||||
|
type: "appstore",
|
||||||
if api_key
|
readonly: options.fetch(:readonly),
|
||||||
begin
|
|
||||||
latest = latest_testflight_build_number(
|
|
||||||
app_identifier: APP_IDENTIFIER,
|
app_identifier: APP_IDENTIFIER,
|
||||||
version: version,
|
team_id: TEAM_ID,
|
||||||
api_key: api_key,
|
profile_name: PROFILE_NAME,
|
||||||
initial_build_number: local_build_number
|
git_url: ENV.fetch("MATCH_GIT_URL"),
|
||||||
).to_i
|
git_branch: "master",
|
||||||
build_number = [latest + 1, local_build_number + 1].max.to_s
|
git_full_name: "Sybil Release Bot",
|
||||||
rescue StandardError => e
|
git_user_email: "james.magahern@me.com",
|
||||||
UI.important("Could not look up TestFlight build number: #{e.message}")
|
api_key: options.fetch(:api_key)
|
||||||
UI.important("Using checked-in build number + 1: #{build_number}")
|
)
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
|
desc "Create or update match signing assets"
|
||||||
|
lane :setup_signing do
|
||||||
|
sync_signing(api_key: app_store_api_key, readonly: false)
|
||||||
|
end
|
||||||
|
|
||||||
sh("xcodegen --spec #{PROJECT_SPEC.shellescape}")
|
desc "Build and upload to TestFlight"
|
||||||
|
lane :beta do
|
||||||
|
prepare_ci_keychain
|
||||||
|
|
||||||
xcode_args = [
|
api_key = app_store_api_key
|
||||||
"-allowProvisioningUpdates",
|
|
||||||
xcode_build_setting("MARKETING_VERSION", version),
|
|
||||||
xcode_build_setting("CURRENT_PROJECT_VERSION", build_number)
|
|
||||||
].join(" ")
|
|
||||||
|
|
||||||
ipa_path = build_app(
|
version = release_version
|
||||||
|
stamp_marketing_version(version)
|
||||||
|
sh("xcodegen", "--spec", PROJECT_SPEC)
|
||||||
|
|
||||||
|
increment_version_number(version_number: version, xcodeproj: PROJECT_FILE)
|
||||||
|
increment_build_number(build_number: build_number, xcodeproj: PROJECT_FILE)
|
||||||
|
|
||||||
|
sync_signing(api_key: api_key, readonly: true)
|
||||||
|
|
||||||
|
build_app(
|
||||||
project: PROJECT_FILE,
|
project: PROJECT_FILE,
|
||||||
scheme: SCHEME,
|
scheme: SCHEME,
|
||||||
clean: true,
|
|
||||||
sdk: "iphoneos",
|
|
||||||
export_method: "app-store",
|
export_method: "app-store",
|
||||||
output_directory: File.join(IOS_ROOT, "build/fastlane"),
|
xcargs: [
|
||||||
output_name: "Sybil-#{version}-#{build_number}.ipa",
|
"DEVELOPMENT_TEAM=#{TEAM_ID.shellescape}",
|
||||||
xcargs: xcode_args,
|
"CODE_SIGN_STYLE=Manual",
|
||||||
export_xcargs: "-allowProvisioningUpdates",
|
"CODE_SIGN_IDENTITY=Apple\\ Distribution",
|
||||||
|
"PROVISIONING_PROFILE_SPECIFIER=#{PROFILE_NAME.shellescape}"
|
||||||
|
].join(" "),
|
||||||
export_options: {
|
export_options: {
|
||||||
method: "app-store-connect",
|
signingStyle: "manual",
|
||||||
destination: "export",
|
|
||||||
signingStyle: "automatic",
|
|
||||||
teamID: TEAM_ID,
|
teamID: TEAM_ID,
|
||||||
manageAppVersionAndBuildNumber: false,
|
provisioningProfiles: {
|
||||||
uploadSymbols: true,
|
APP_IDENTIFIER => PROFILE_NAME
|
||||||
stripSwiftSymbols: true
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
ipa_path ||= lane_context[SharedValues::IPA_OUTPUT_PATH]
|
upload_to_testflight(
|
||||||
UI.user_error!("IPA export failed; no IPA path was returned") unless present?(ipa_path) && File.exist?(ipa_path)
|
api_key: api_key,
|
||||||
|
skip_waiting_for_build_processing: true
|
||||||
password = ENV["FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD"]
|
)
|
||||||
UI.user_error!("FASTLANE_USER is required for altool upload") unless present?(ENV["FASTLANE_USER"])
|
ensure
|
||||||
UI.user_error!("FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD is required for altool upload") unless present?(password)
|
delete_keychain(name: CI_KEYCHAIN_NAME) if ci? && File.file?(CI_KEYCHAIN_DB_PATH)
|
||||||
UI.user_error!("SYBIL_APP_STORE_APPLE_ID is required for altool upload") unless present?(APP_STORE_APPLE_ID)
|
|
||||||
UI.user_error!("SYBIL_PROVIDER_PUBLIC_ID is required for altool upload") unless present?(PROVIDER_PUBLIC_ID)
|
|
||||||
|
|
||||||
ENV["ITMS_TRANSPORTER_PASSWORD"] = password
|
|
||||||
sh([
|
|
||||||
"xcrun altool",
|
|
||||||
"--upload-package #{ipa_path.shellescape}",
|
|
||||||
"--platform ios",
|
|
||||||
"--apple-id #{APP_STORE_APPLE_ID.shellescape}",
|
|
||||||
"--bundle-id #{APP_IDENTIFIER.shellescape}",
|
|
||||||
"--bundle-version #{build_number.shellescape}",
|
|
||||||
"--bundle-short-version-string #{version.shellescape}",
|
|
||||||
"--provider-public-id #{PROVIDER_PUBLIC_ID.shellescape}",
|
|
||||||
"--username #{ENV.fetch("FASTLANE_USER").shellescape}",
|
|
||||||
"--password @env:ITMS_TRANSPORTER_PASSWORD",
|
|
||||||
"--show-progress"
|
|
||||||
].join(" "))
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
fastlane documentation
|
|
||||||
----
|
|
||||||
|
|
||||||
# Installation
|
|
||||||
|
|
||||||
Make sure you have the latest version of the Xcode command line tools installed:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
xcode-select --install
|
|
||||||
```
|
|
||||||
|
|
||||||
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
|
||||||
|
|
||||||
# Available Actions
|
|
||||||
|
|
||||||
## iOS
|
|
||||||
|
|
||||||
### ios version
|
|
||||||
|
|
||||||
```sh
|
|
||||||
[bundle exec] fastlane ios version
|
|
||||||
```
|
|
||||||
|
|
||||||
Show the version Fastlane will stamp into the next TestFlight archive
|
|
||||||
|
|
||||||
### ios beta
|
|
||||||
|
|
||||||
```sh
|
|
||||||
[bundle exec] fastlane ios beta
|
|
||||||
```
|
|
||||||
|
|
||||||
Build Sybil and upload it to TestFlight
|
|
||||||
|
|
||||||
----
|
|
||||||
|
|
||||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
|
||||||
|
|
||||||
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
|
||||||
|
|
||||||
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
|
||||||
+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,
|
||||||
|
|||||||
+19
-599
@@ -4,20 +4,14 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { convert as htmlToText } from "html-to-text";
|
import { convert as htmlToText } from "html-to-text";
|
||||||
import type OpenAI from "openai";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { buildBrowserLikeNavigationHeaders } from "../browser-fetch-headers.js";
|
import { buildBrowserLikeNavigationHeaders } from "../browser-fetch-headers.js";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
import { exaClient } from "../search/exa.js";
|
import { exaClient } from "../search/exa.js";
|
||||||
import { searchSearxng } from "../search/searxng.js";
|
import { searchSearxng } from "../search/searxng.js";
|
||||||
import {
|
|
||||||
buildOpenAIConversationMessage,
|
|
||||||
buildOpenAIResponsesInputMessage,
|
|
||||||
buildSystemPromptAugmentationMessage,
|
|
||||||
} from "./message-content.js";
|
|
||||||
import type { ChatMessage } from "./types.js";
|
import type { ChatMessage } from "./types.js";
|
||||||
|
|
||||||
const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
|
export const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
|
||||||
const DEFAULT_WEB_RESULTS = 5;
|
const DEFAULT_WEB_RESULTS = 5;
|
||||||
const MAX_WEB_RESULTS = 10;
|
const MAX_WEB_RESULTS = 10;
|
||||||
const DEFAULT_FETCH_MAX_CHARACTERS = 12_000;
|
const DEFAULT_FETCH_MAX_CHARACTERS = 12_000;
|
||||||
@@ -30,7 +24,7 @@ const MAX_SHELL_COMMAND_CHARACTERS = 20_000;
|
|||||||
const DEFAULT_SHELL_MAX_OUTPUT_CHARACTERS = 24_000;
|
const DEFAULT_SHELL_MAX_OUTPUT_CHARACTERS = 24_000;
|
||||||
const MAX_SHELL_MAX_OUTPUT_CHARACTERS = 80_000;
|
const MAX_SHELL_MAX_OUTPUT_CHARACTERS = 80_000;
|
||||||
const REMOTE_EXEC_MAX_BUFFER_BYTES = 1_000_000;
|
const REMOTE_EXEC_MAX_BUFFER_BYTES = 1_000_000;
|
||||||
const MAX_DANGLING_TOOL_INTENT_RETRIES = 1;
|
export const MAX_DANGLING_TOOL_INTENT_RETRIES = 1;
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@@ -220,7 +214,7 @@ function getEnabledToolSet(params: Pick<ToolAwareCompletionParams, "enabledTools
|
|||||||
return new Set(normalizeEnabledChatTools(params.enabledTools));
|
return new Set(normalizeEnabledChatTools(params.enabledTools));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEnabledChatTools(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
export function getEnabledChatTools(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||||
const enabled = getEnabledToolSet(params);
|
const enabled = getEnabledToolSet(params);
|
||||||
return CHAT_TOOLS.filter((tool) => {
|
return CHAT_TOOLS.filter((tool) => {
|
||||||
const name = getToolName(tool);
|
const name = getToolName(tool);
|
||||||
@@ -228,19 +222,6 @@ function getEnabledChatTools(params: Pick<ToolAwareCompletionParams, "enabledToo
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toResponsesChatTools(tools: any[]) {
|
|
||||||
return tools.map((tool) => {
|
|
||||||
if (tool?.type !== "function") return tool;
|
|
||||||
return {
|
|
||||||
type: "function",
|
|
||||||
name: tool.function.name,
|
|
||||||
description: tool.function.description,
|
|
||||||
parameters: tool.function.parameters,
|
|
||||||
strict: false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CHAT_TOOL_SYSTEM_PROMPT =
|
export const CHAT_TOOL_SYSTEM_PROMPT =
|
||||||
"You can use tools to gather up-to-date web information when needed. " +
|
"You can use tools to gather up-to-date web information when needed. " +
|
||||||
"Use web_search for discovery and recent facts, and fetch_url to read the full content of a specific page. " +
|
"Use web_search for discovery and recent facts, and fetch_url to read the full content of a specific page. " +
|
||||||
@@ -254,18 +235,18 @@ export const CHAT_TOOL_SYSTEM_PROMPT =
|
|||||||
: "") +
|
: "") +
|
||||||
"Do not fabricate tool outputs; reason only from provided tool results.";
|
"Do not fabricate tool outputs; reason only from provided tool results.";
|
||||||
|
|
||||||
type ToolRunOutcome = {
|
export type ToolRunOutcome = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ToolAwareUsage = {
|
export type ToolAwareUsage = {
|
||||||
inputTokens?: number;
|
inputTokens?: number;
|
||||||
outputTokens?: number;
|
outputTokens?: number;
|
||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ToolAwareCompletionResult = {
|
export type ToolAwareCompletionResult = {
|
||||||
text: string;
|
text: string;
|
||||||
usage?: ToolAwareUsage;
|
usage?: ToolAwareUsage;
|
||||||
raw: unknown;
|
raw: unknown;
|
||||||
@@ -277,8 +258,8 @@ export type ToolAwareStreamingEvent =
|
|||||||
| { type: "tool_call"; event: ToolExecutionEvent }
|
| { type: "tool_call"; event: ToolExecutionEvent }
|
||||||
| { type: "done"; result: ToolAwareCompletionResult };
|
| { type: "done"; result: ToolAwareCompletionResult };
|
||||||
|
|
||||||
type ToolAwareCompletionParams = {
|
export type ToolAwareCompletionParams = {
|
||||||
client: OpenAI;
|
client: any;
|
||||||
model: string;
|
model: string;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
enabledTools?: string[];
|
enabledTools?: string[];
|
||||||
@@ -440,7 +421,7 @@ function extractHtmlTitle(html: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildChatToolSystemPrompt(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
export function buildChatToolSystemPrompt(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||||
const enabled = getEnabledToolSet(params);
|
const enabled = getEnabledToolSet(params);
|
||||||
return (
|
return (
|
||||||
"You can use tools to gather up-to-date web information when needed. " +
|
"You can use tools to gather up-to-date web information when needed. " +
|
||||||
@@ -458,22 +439,6 @@ function buildChatToolSystemPrompt(params: Pick<ToolAwareCompletionParams, "enab
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeIncomingMessages(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
|
||||||
const normalized = messages.map((message) => buildOpenAIConversationMessage(message));
|
|
||||||
|
|
||||||
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePlainIncomingMessages(messages: ChatMessage[], userLocation?: string) {
|
|
||||||
return [buildSystemPromptAugmentationMessage(userLocation), ...messages.map((message) => buildOpenAIConversationMessage(message))];
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeIncomingResponsesInput(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
|
||||||
const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message));
|
|
||||||
|
|
||||||
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
|
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
|
||||||
const exa = exaClient();
|
const exa = exaClient();
|
||||||
const response = await exa.search(args.query, {
|
const response = await exa.search(args.query, {
|
||||||
@@ -842,7 +807,7 @@ async function executeTool(name: string, args: unknown): Promise<ToolRunOutcome>
|
|||||||
return { ok: false, error: `Unknown tool: ${name}` };
|
return { ok: false, error: `Unknown tool: ${name}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseToolArgs(raw: unknown) {
|
export function parseToolArgs(raw: unknown) {
|
||||||
if (typeof raw !== "string") return {};
|
if (typeof raw !== "string") return {};
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return {};
|
if (!trimmed) return {};
|
||||||
@@ -871,7 +836,7 @@ function buildEventArgs(name: string, args: Record<string, unknown>) {
|
|||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeDanglingToolIntent(text: string) {
|
export function looksLikeDanglingToolIntent(text: string) {
|
||||||
const normalized = text
|
const normalized = text
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[`*_>#-]/g, " ")
|
.replace(/[`*_>#-]/g, " ")
|
||||||
@@ -887,7 +852,7 @@ function looksLikeDanglingToolIntent(text: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendDanglingToolIntentCorrection(conversation: any[], text: string) {
|
export function appendDanglingToolIntentCorrection(conversation: any[], text: string) {
|
||||||
conversation.push({ role: "assistant", content: text });
|
conversation.push({ role: "assistant", content: text });
|
||||||
conversation.push({
|
conversation.push({
|
||||||
role: "system",
|
role: "system",
|
||||||
@@ -896,7 +861,7 @@ function appendDanglingToolIntentCorrection(conversation: any[], text: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
export function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||||
if (!usage) return false;
|
if (!usage) return false;
|
||||||
acc.inputTokens += usage.prompt_tokens ?? 0;
|
acc.inputTokens += usage.prompt_tokens ?? 0;
|
||||||
acc.outputTokens += usage.completion_tokens ?? 0;
|
acc.outputTokens += usage.completion_tokens ?? 0;
|
||||||
@@ -904,79 +869,19 @@ function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeResponsesUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
export function getUnstreamedText(finalText: string, streamedText: string) {
|
||||||
if (!usage) return false;
|
|
||||||
acc.inputTokens += usage.input_tokens ?? 0;
|
|
||||||
acc.outputTokens += usage.output_tokens ?? 0;
|
|
||||||
acc.totalTokens += usage.total_tokens ?? 0;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getResponseOutputItems(response: any) {
|
|
||||||
return Array.isArray(response?.output) ? response.output : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractResponsesText(response: any, fallback = "") {
|
|
||||||
if (typeof response?.output_text === "string") return response.output_text;
|
|
||||||
|
|
||||||
const parts: string[] = [];
|
|
||||||
for (const item of getResponseOutputItems(response)) {
|
|
||||||
if (item?.type !== "message" || !Array.isArray(item.content)) continue;
|
|
||||||
for (const content of item.content) {
|
|
||||||
if (content?.type === "output_text" && typeof content.text === "string") {
|
|
||||||
parts.push(content.text);
|
|
||||||
} else if (content?.type === "refusal" && typeof content.refusal === "string") {
|
|
||||||
parts.push(content.refusal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parts.join("") || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractChatCompletionContent(message: any) {
|
|
||||||
if (typeof message?.content === "string") return message.content;
|
|
||||||
if (!Array.isArray(message?.content)) return "";
|
|
||||||
|
|
||||||
return message.content
|
|
||||||
.map((part: any) => {
|
|
||||||
if (typeof part === "string") return part;
|
|
||||||
if (typeof part?.text === "string") return part.text;
|
|
||||||
if (typeof part?.content === "string") return part.content;
|
|
||||||
return "";
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUnstreamedText(finalText: string, streamedText: string) {
|
|
||||||
if (!finalText) return "";
|
if (!finalText) return "";
|
||||||
if (!streamedText) return finalText;
|
if (!streamedText) return finalText;
|
||||||
return finalText.startsWith(streamedText) ? finalText.slice(streamedText.length) : "";
|
return finalText.startsWith(streamedText) ? finalText.slice(streamedText.length) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getResponseFailureMessage(response: any) {
|
export type NormalizedToolCall = {
|
||||||
if (response?.status !== "failed" && response?.status !== "incomplete") return null;
|
|
||||||
const errorMessage = typeof response?.error?.message === "string" ? response.error.message : null;
|
|
||||||
const incompleteReason = typeof response?.incomplete_details?.reason === "string" ? response.incomplete_details.reason : null;
|
|
||||||
return errorMessage ?? (incompleteReason ? `Response incomplete: ${incompleteReason}` : `Response ${response.status}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeResponsesToolCalls(outputItems: any[], round: number): NormalizedToolCall[] {
|
|
||||||
return outputItems
|
|
||||||
.filter((item) => item?.type === "function_call")
|
|
||||||
.map((call: any, index: number) => ({
|
|
||||||
id: call.call_id ?? call.id ?? `tool_call_${round}_${index}`,
|
|
||||||
name: call.name ?? "unknown_tool",
|
|
||||||
arguments: call.arguments ?? "{}",
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
type NormalizedToolCall = {
|
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
arguments: string;
|
arguments: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToolCall[] {
|
export function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToolCall[] {
|
||||||
return toolCalls.map((call: any, index: number) => ({
|
return toolCalls.map((call: any, index: number) => ({
|
||||||
id: call?.id ?? `tool_call_${round}_${index}`,
|
id: call?.id ?? `tool_call_${round}_${index}`,
|
||||||
name: call?.function?.name ?? "unknown_tool",
|
name: call?.function?.name ?? "unknown_tool",
|
||||||
@@ -984,7 +889,7 @@ function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToo
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
type PreparedToolCallExecution = {
|
export type PreparedToolCallExecution = {
|
||||||
startedAtMs: number;
|
startedAtMs: number;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
parsedArgs: Record<string, unknown>;
|
parsedArgs: Record<string, unknown>;
|
||||||
@@ -992,7 +897,7 @@ type PreparedToolCallExecution = {
|
|||||||
parseError?: unknown;
|
parseError?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
function prepareToolCallExecution(call: NormalizedToolCall): { event: ToolExecutionEvent; execution: PreparedToolCallExecution } {
|
export function prepareToolCallExecution(call: NormalizedToolCall): { event: ToolExecutionEvent; execution: PreparedToolCallExecution } {
|
||||||
const startedAtMs = Date.now();
|
const startedAtMs = Date.now();
|
||||||
const startedAt = new Date(startedAtMs).toISOString();
|
const startedAt = new Date(startedAtMs).toISOString();
|
||||||
let parsedArgs: Record<string, unknown> = {};
|
let parsedArgs: Record<string, unknown> = {};
|
||||||
@@ -1024,7 +929,7 @@ function prepareToolCallExecution(call: NormalizedToolCall): { event: ToolExecut
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeToolCallAndBuildEvent(
|
export async function executeToolCallAndBuildEvent(
|
||||||
call: NormalizedToolCall,
|
call: NormalizedToolCall,
|
||||||
execution: PreparedToolCallExecution,
|
execution: PreparedToolCallExecution,
|
||||||
params: ToolAwareCompletionParams
|
params: ToolAwareCompletionParams
|
||||||
@@ -1068,488 +973,3 @@ async function executeToolCallAndBuildEvent(
|
|||||||
|
|
||||||
return { event, toolResult };
|
return { event, toolResult };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
|
||||||
const enabledTools = getEnabledChatTools(params);
|
|
||||||
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
|
|
||||||
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 params.client.responses.create({
|
|
||||||
model: params.model,
|
|
||||||
input,
|
|
||||||
temperature: params.temperature,
|
|
||||||
max_output_tokens: params.maxTokens,
|
|
||||||
tools: toResponsesChatTools(enabledTools),
|
|
||||||
tool_choice: "auto",
|
|
||||||
parallel_tool_calls: true,
|
|
||||||
// Tool loops pass response output items back as input; reasoning items need persistence.
|
|
||||||
store: true,
|
|
||||||
} as any);
|
|
||||||
rawResponses.push(response);
|
|
||||||
sawUsage = mergeResponsesUsage(usageAcc, response?.usage) || sawUsage;
|
|
||||||
|
|
||||||
const failureMessage = getResponseFailureMessage(response);
|
|
||||||
if (failureMessage) {
|
|
||||||
throw new Error(failureMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputItems = getResponseOutputItems(response);
|
|
||||||
const normalizedToolCalls = normalizeResponsesToolCalls(outputItems, round);
|
|
||||||
if (!normalizedToolCalls.length) {
|
|
||||||
const text = extractResponsesText(response);
|
|
||||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
|
||||||
danglingToolIntentRetries += 1;
|
|
||||||
appendDanglingToolIntentCorrection(input, text);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
text,
|
|
||||||
usage: sawUsage ? usageAcc : undefined,
|
|
||||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, api: "responses" },
|
|
||||||
toolEvents,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
totalToolCalls += normalizedToolCalls.length;
|
|
||||||
input.push(...outputItems);
|
|
||||||
|
|
||||||
for (const call of normalizedToolCalls) {
|
|
||||||
const { execution } = prepareToolCallExecution(call);
|
|
||||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
|
||||||
toolEvents.push(event);
|
|
||||||
|
|
||||||
input.push({
|
|
||||||
type: "function_call_output",
|
|
||||||
call_id: call.id,
|
|
||||||
output: JSON.stringify(toolResult),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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: "responses" },
|
|
||||||
toolEvents,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
|
||||||
const enabledTools = getEnabledChatTools(params);
|
|
||||||
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
|
|
||||||
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 completion = await params.client.chat.completions.create({
|
|
||||||
model: params.model,
|
|
||||||
messages: conversation,
|
|
||||||
temperature: params.temperature,
|
|
||||||
max_tokens: params.maxTokens,
|
|
||||||
tools: enabledTools,
|
|
||||||
tool_choice: "auto",
|
|
||||||
} as any);
|
|
||||||
rawResponses.push(completion);
|
|
||||||
sawUsage = mergeUsage(usageAcc, completion?.usage) || sawUsage;
|
|
||||||
|
|
||||||
const message = completion?.choices?.[0]?.message;
|
|
||||||
if (!message) {
|
|
||||||
return {
|
|
||||||
text: "",
|
|
||||||
usage: sawUsage ? usageAcc : undefined,
|
|
||||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, missingMessage: true },
|
|
||||||
toolEvents,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
||||||
if (!toolCalls.length) {
|
|
||||||
const text = typeof message.content === "string" ? message.content : "";
|
|
||||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
|
||||||
danglingToolIntentRetries += 1;
|
|
||||||
appendDanglingToolIntentCorrection(conversation, text);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
text,
|
|
||||||
usage: sawUsage ? usageAcc : undefined,
|
|
||||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls },
|
|
||||||
toolEvents,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedToolCalls = normalizeModelToolCalls(toolCalls, round);
|
|
||||||
totalToolCalls += normalizedToolCalls.length;
|
|
||||||
|
|
||||||
const assistantToolCallMessage: any = {
|
|
||||||
role: "assistant",
|
|
||||||
tool_calls: normalizedToolCalls.map((call) => ({
|
|
||||||
id: call.id,
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: call.name,
|
|
||||||
arguments: call.arguments,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
if (typeof message.content === "string" && message.content.length) {
|
|
||||||
assistantToolCallMessage.content = message.content;
|
|
||||||
}
|
|
||||||
conversation.push(assistantToolCallMessage);
|
|
||||||
|
|
||||||
for (const call of normalizedToolCalls) {
|
|
||||||
const { execution } = prepareToolCallExecution(call);
|
|
||||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
|
||||||
toolEvents.push(event);
|
|
||||||
|
|
||||||
conversation.push({
|
|
||||||
role: "tool",
|
|
||||||
tool_call_id: call.id,
|
|
||||||
content: JSON.stringify(toolResult),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 },
|
|
||||||
toolEvents,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runPlainChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
|
||||||
const completion = await params.client.chat.completions.create({
|
|
||||||
model: params.model,
|
|
||||||
messages: normalizePlainIncomingMessages(params.messages, params.userLocation),
|
|
||||||
temperature: params.temperature,
|
|
||||||
max_tokens: params.maxTokens,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
||||||
const sawUsage = mergeUsage(usageAcc, completion?.usage);
|
|
||||||
const message = completion?.choices?.[0]?.message;
|
|
||||||
|
|
||||||
return {
|
|
||||||
text: extractChatCompletionContent(message),
|
|
||||||
usage: sawUsage ? usageAcc : undefined,
|
|
||||||
raw: { response: completion, api: "chat.completions" },
|
|
||||||
toolEvents: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function* runToolAwareOpenAIChatStream(
|
|
||||||
params: ToolAwareCompletionParams
|
|
||||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
|
||||||
const enabledTools = getEnabledChatTools(params);
|
|
||||||
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
|
|
||||||
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 stream = await params.client.responses.create({
|
|
||||||
model: params.model,
|
|
||||||
input,
|
|
||||||
temperature: params.temperature,
|
|
||||||
max_output_tokens: params.maxTokens,
|
|
||||||
tools: toResponsesChatTools(enabledTools),
|
|
||||||
tool_choice: "auto",
|
|
||||||
parallel_tool_calls: true,
|
|
||||||
// Tool loops pass response output items back as input; reasoning items need persistence.
|
|
||||||
store: true,
|
|
||||||
stream: true,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
let roundText = "";
|
|
||||||
let streamedRoundText = "";
|
|
||||||
let roundHasToolCalls = false;
|
|
||||||
let canStreamRoundText = false;
|
|
||||||
let completedResponse: any | null = null;
|
|
||||||
const completedOutputItems: any[] = [];
|
|
||||||
|
|
||||||
for await (const event of stream as any as AsyncIterable<any>) {
|
|
||||||
rawResponses.push(event);
|
|
||||||
|
|
||||||
if (event?.type === "response.output_text.delta" && typeof event.delta === "string") {
|
|
||||||
roundText += event.delta;
|
|
||||||
if (canStreamRoundText && !roundHasToolCalls && event.delta.length) {
|
|
||||||
streamedRoundText += event.delta;
|
|
||||||
yield { type: "delta", text: event.delta };
|
|
||||||
}
|
|
||||||
} else if (event?.type === "response.output_item.added" && event.item) {
|
|
||||||
if (event.item.type === "function_call") {
|
|
||||||
roundHasToolCalls = true;
|
|
||||||
canStreamRoundText = false;
|
|
||||||
} else if (event.item.type === "message" && !roundHasToolCalls) {
|
|
||||||
canStreamRoundText = true;
|
|
||||||
}
|
|
||||||
} else if (event?.type === "response.output_item.done" && event.item) {
|
|
||||||
completedOutputItems[event.output_index ?? completedOutputItems.length] = event.item;
|
|
||||||
if (event.item.type === "function_call") {
|
|
||||||
roundHasToolCalls = true;
|
|
||||||
canStreamRoundText = false;
|
|
||||||
}
|
|
||||||
} else if (event?.type === "response.completed") {
|
|
||||||
completedResponse = event.response;
|
|
||||||
sawUsage = mergeResponsesUsage(usageAcc, event.response?.usage) || sawUsage;
|
|
||||||
} else if (event?.type === "response.failed" || event?.type === "response.incomplete") {
|
|
||||||
completedResponse = event.response;
|
|
||||||
sawUsage = mergeResponsesUsage(usageAcc, event.response?.usage) || sawUsage;
|
|
||||||
} else if (event?.type === "error") {
|
|
||||||
throw new Error(event.message ?? "OpenAI Responses stream failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const failureMessage = getResponseFailureMessage(completedResponse);
|
|
||||||
if (failureMessage) {
|
|
||||||
throw new Error(failureMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputItems = getResponseOutputItems(completedResponse);
|
|
||||||
const responseOutputItems = outputItems.length ? outputItems : completedOutputItems.filter(Boolean);
|
|
||||||
const normalizedToolCalls = normalizeResponsesToolCalls(responseOutputItems, round);
|
|
||||||
if (!normalizedToolCalls.length) {
|
|
||||||
const text = extractResponsesText(completedResponse, roundText);
|
|
||||||
if (
|
|
||||||
!streamedRoundText &&
|
|
||||||
danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES &&
|
|
||||||
looksLikeDanglingToolIntent(text)
|
|
||||||
) {
|
|
||||||
danglingToolIntentRetries += 1;
|
|
||||||
appendDanglingToolIntentCorrection(input, text);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const unstreamedText = getUnstreamedText(text, streamedRoundText);
|
|
||||||
if (unstreamedText) {
|
|
||||||
yield { type: "delta", text: unstreamedText };
|
|
||||||
}
|
|
||||||
yield {
|
|
||||||
type: "done",
|
|
||||||
result: {
|
|
||||||
text,
|
|
||||||
usage: sawUsage ? usageAcc : undefined,
|
|
||||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "responses" },
|
|
||||||
toolEvents,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
totalToolCalls += normalizedToolCalls.length;
|
|
||||||
input.push(...responseOutputItems);
|
|
||||||
|
|
||||||
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 };
|
|
||||||
input.push({
|
|
||||||
type: "function_call_output",
|
|
||||||
call_id: call.id,
|
|
||||||
output: JSON.stringify(toolResult),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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: "responses" },
|
|
||||||
toolEvents,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function* runToolAwareChatCompletionsStream(
|
|
||||||
params: ToolAwareCompletionParams
|
|
||||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
|
||||||
const enabledTools = getEnabledChatTools(params);
|
|
||||||
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
|
|
||||||
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 stream = await params.client.chat.completions.create({
|
|
||||||
model: params.model,
|
|
||||||
messages: conversation,
|
|
||||||
temperature: params.temperature,
|
|
||||||
max_tokens: params.maxTokens,
|
|
||||||
tools: enabledTools,
|
|
||||||
tool_choice: "auto",
|
|
||||||
stream: true,
|
|
||||||
stream_options: { include_usage: true },
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
let roundText = "";
|
|
||||||
let streamedRoundText = "";
|
|
||||||
let roundHasToolCalls = false;
|
|
||||||
const roundToolCalls = new Map<number, { id?: string; name?: string; arguments: string }>();
|
|
||||||
|
|
||||||
for await (const chunk of stream as any as AsyncIterable<any>) {
|
|
||||||
rawResponses.push(chunk);
|
|
||||||
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
|
|
||||||
|
|
||||||
const choice = chunk?.choices?.[0];
|
|
||||||
const deltaText = choice?.delta?.content ?? "";
|
|
||||||
if (typeof deltaText === "string" && deltaText.length) {
|
|
||||||
roundText += deltaText;
|
|
||||||
if (!roundHasToolCalls) {
|
|
||||||
streamedRoundText += deltaText;
|
|
||||||
yield { type: "delta", text: deltaText };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deltaToolCalls = Array.isArray(choice?.delta?.tool_calls) ? choice.delta.tool_calls : [];
|
|
||||||
if (deltaToolCalls.length) {
|
|
||||||
roundHasToolCalls = true;
|
|
||||||
}
|
|
||||||
for (const toolCall of deltaToolCalls) {
|
|
||||||
const idx = typeof toolCall?.index === "number" ? toolCall.index : 0;
|
|
||||||
const entry = roundToolCalls.get(idx) ?? { arguments: "" };
|
|
||||||
if (typeof toolCall?.id === "string" && toolCall.id.length) {
|
|
||||||
entry.id = toolCall.id;
|
|
||||||
}
|
|
||||||
if (typeof toolCall?.function?.name === "string" && toolCall.function.name.length) {
|
|
||||||
entry.name = toolCall.function.name;
|
|
||||||
}
|
|
||||||
if (typeof toolCall?.function?.arguments === "string" && toolCall.function.arguments.length) {
|
|
||||||
entry.arguments += toolCall.function.arguments;
|
|
||||||
}
|
|
||||||
roundToolCalls.set(idx, entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedToolCalls: NormalizedToolCall[] = [...roundToolCalls.entries()]
|
|
||||||
.sort((a, b) => a[0] - b[0])
|
|
||||||
.map(([_, call], index) => ({
|
|
||||||
id: call.id ?? `tool_call_${round}_${index}`,
|
|
||||||
name: call.name ?? "unknown_tool",
|
|
||||||
arguments: call.arguments || "{}",
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (!normalizedToolCalls.length) {
|
|
||||||
if (
|
|
||||||
!streamedRoundText &&
|
|
||||||
danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES &&
|
|
||||||
looksLikeDanglingToolIntent(roundText)
|
|
||||||
) {
|
|
||||||
danglingToolIntentRetries += 1;
|
|
||||||
appendDanglingToolIntentCorrection(conversation, roundText);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const unstreamedText = getUnstreamedText(roundText, streamedRoundText);
|
|
||||||
if (unstreamedText) {
|
|
||||||
yield { type: "delta", text: unstreamedText };
|
|
||||||
}
|
|
||||||
yield {
|
|
||||||
type: "done",
|
|
||||||
result: {
|
|
||||||
text: roundText,
|
|
||||||
usage: sawUsage ? usageAcc : undefined,
|
|
||||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls },
|
|
||||||
toolEvents,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
totalToolCalls += normalizedToolCalls.length;
|
|
||||||
const assistantToolCallMessage: any = {
|
|
||||||
role: "assistant",
|
|
||||||
tool_calls: normalizedToolCalls.map((call) => ({
|
|
||||||
id: call.id,
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: call.name,
|
|
||||||
arguments: call.arguments,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
if (roundText) {
|
|
||||||
assistantToolCallMessage.content = roundText;
|
|
||||||
}
|
|
||||||
conversation.push(assistantToolCallMessage);
|
|
||||||
|
|
||||||
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 };
|
|
||||||
conversation.push({
|
|
||||||
role: "tool",
|
|
||||||
tool_call_id: call.id,
|
|
||||||
content: JSON.stringify(toolResult),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 },
|
|
||||||
toolEvents,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function* runPlainChatCompletionsStream(
|
|
||||||
params: ToolAwareCompletionParams
|
|
||||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
|
||||||
const rawResponses: unknown[] = [];
|
|
||||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
||||||
let sawUsage = false;
|
|
||||||
let text = "";
|
|
||||||
|
|
||||||
const stream = await params.client.chat.completions.create({
|
|
||||||
model: params.model,
|
|
||||||
messages: normalizePlainIncomingMessages(params.messages, params.userLocation),
|
|
||||||
temperature: params.temperature,
|
|
||||||
max_tokens: params.maxTokens,
|
|
||||||
stream: true,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
for await (const chunk of stream as any as AsyncIterable<any>) {
|
|
||||||
rawResponses.push(chunk);
|
|
||||||
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
|
|
||||||
|
|
||||||
const deltaText = chunk?.choices?.[0]?.delta?.content ?? "";
|
|
||||||
if (typeof deltaText === "string" && deltaText.length) {
|
|
||||||
text += deltaText;
|
|
||||||
yield { type: "delta", text: deltaText };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
yield {
|
|
||||||
type: "done",
|
|
||||||
result: {
|
|
||||||
text,
|
|
||||||
usage: sawUsage ? usageAcc : undefined,
|
|
||||||
raw: { streamed: true, responses: rawResponses, api: "chat.completions" },
|
|
||||||
toolEvents: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,21 +18,21 @@ function escapeAttribute(value: string) {
|
|||||||
return value.replace(/"/g, """);
|
return value.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getImageAttachments(message: ChatMessage) {
|
export function getImageAttachments(message: ChatMessage) {
|
||||||
return (message.attachments ?? []).filter((attachment): attachment is ChatImageAttachment => attachment.kind === "image");
|
return (message.attachments ?? []).filter((attachment): attachment is ChatImageAttachment => attachment.kind === "image");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTextAttachments(message: ChatMessage) {
|
export function getTextAttachments(message: ChatMessage) {
|
||||||
return (message.attachments ?? []).filter((attachment): attachment is ChatTextAttachment => attachment.kind === "text");
|
return (message.attachments ?? []).filter((attachment): attachment is ChatTextAttachment => attachment.kind === "text");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildImageSummaryText(attachments: ChatImageAttachment[]) {
|
export function buildImageSummaryText(attachments: ChatImageAttachment[]) {
|
||||||
if (!attachments.length) return null;
|
if (!attachments.length) return null;
|
||||||
const label = attachments.length === 1 ? "Attached image" : "Attached images";
|
const label = attachments.length === 1 ? "Attached image" : "Attached images";
|
||||||
return `${label}: ${attachments.map((attachment) => attachment.filename).join(", ")}.`;
|
return `${label}: ${attachments.map((attachment) => attachment.filename).join(", ")}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTextAttachmentPrompt(attachment: ChatTextAttachment) {
|
export function buildTextAttachmentPrompt(attachment: ChatTextAttachment) {
|
||||||
const truncationNote = attachment.truncated ? ' truncated="true"' : "";
|
const truncationNote = attachment.truncated ? ' truncated="true"' : "";
|
||||||
return [
|
return [
|
||||||
`Attached text file: ${attachment.filename}${attachment.truncated ? " (content truncated)" : ""}`,
|
`Attached text file: ${attachment.filename}${attachment.truncated ? " (content truncated)" : ""}`,
|
||||||
@@ -42,83 +42,7 @@ function buildTextAttachmentPrompt(attachment: ChatTextAttachment) {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function toOpenAIContent(message: ChatMessage) {
|
export function parseImageDataUrl(attachment: ChatImageAttachment) {
|
||||||
const imageAttachments = getImageAttachments(message);
|
|
||||||
const textAttachments = getTextAttachments(message);
|
|
||||||
if (!imageAttachments.length && !textAttachments.length) {
|
|
||||||
return message.content;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts: Array<Record<string, unknown>> = [];
|
|
||||||
|
|
||||||
for (const attachment of imageAttachments) {
|
|
||||||
parts.push({
|
|
||||||
type: "image_url",
|
|
||||||
image_url: {
|
|
||||||
url: attachment.dataUrl,
|
|
||||||
detail: "auto",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageSummary = buildImageSummaryText(imageAttachments);
|
|
||||||
if (imageSummary) {
|
|
||||||
parts.push({ type: "text", text: imageSummary });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const attachment of textAttachments) {
|
|
||||||
parts.push({ type: "text", text: buildTextAttachmentPrompt(attachment) });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.content.trim()) {
|
|
||||||
parts.push({ type: "text", text: message.content });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts.length === 1 && parts[0]?.type === "text" && typeof parts[0].text === "string") {
|
|
||||||
return parts[0].text;
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toOpenAIResponsesContent(message: ChatMessage) {
|
|
||||||
const imageAttachments = getImageAttachments(message);
|
|
||||||
const textAttachments = getTextAttachments(message);
|
|
||||||
if (!imageAttachments.length && !textAttachments.length) {
|
|
||||||
return message.content;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts: Array<Record<string, unknown>> = [];
|
|
||||||
|
|
||||||
for (const attachment of imageAttachments) {
|
|
||||||
parts.push({
|
|
||||||
type: "input_image",
|
|
||||||
image_url: attachment.dataUrl,
|
|
||||||
detail: "auto",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageSummary = buildImageSummaryText(imageAttachments);
|
|
||||||
if (imageSummary) {
|
|
||||||
parts.push({ type: "input_text", text: imageSummary });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const attachment of textAttachments) {
|
|
||||||
parts.push({ type: "input_text", text: buildTextAttachmentPrompt(attachment) });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.content.trim()) {
|
|
||||||
parts.push({ type: "input_text", text: message.content });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts.length === 1 && parts[0]?.type === "input_text" && typeof parts[0].text === "string") {
|
|
||||||
return parts[0].text;
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseImageDataUrl(attachment: ChatImageAttachment) {
|
|
||||||
const match = attachment.dataUrl.match(/^data:(image\/(?:png|jpeg));base64,([a-z0-9+/=\s]+)$/i);
|
const match = attachment.dataUrl.match(/^data:(image\/(?:png|jpeg));base64,([a-z0-9+/=\s]+)$/i);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error(`Invalid image attachment data URL for '${attachment.filename}'.`);
|
throw new Error(`Invalid image attachment data URL for '${attachment.filename}'.`);
|
||||||
@@ -135,83 +59,6 @@ function parseImageDataUrl(attachment: ChatImageAttachment) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAnthropicContent(message: ChatMessage) {
|
|
||||||
const imageAttachments = getImageAttachments(message);
|
|
||||||
const textAttachments = getTextAttachments(message);
|
|
||||||
if (!imageAttachments.length && !textAttachments.length) {
|
|
||||||
return message.content;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blocks: Array<Record<string, unknown>> = [];
|
|
||||||
|
|
||||||
for (const attachment of imageAttachments) {
|
|
||||||
const source = parseImageDataUrl(attachment);
|
|
||||||
blocks.push({
|
|
||||||
type: "image",
|
|
||||||
source: {
|
|
||||||
type: "base64",
|
|
||||||
media_type: source.mediaType,
|
|
||||||
data: source.data,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageSummary = buildImageSummaryText(imageAttachments);
|
|
||||||
if (imageSummary) {
|
|
||||||
blocks.push({ type: "text", text: imageSummary });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const attachment of textAttachments) {
|
|
||||||
blocks.push({ type: "text", text: buildTextAttachmentPrompt(attachment) });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.content.trim()) {
|
|
||||||
blocks.push({ type: "text", text: message.content });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (blocks.length === 1 && blocks[0]?.type === "text" && typeof blocks[0].text === "string") {
|
|
||||||
return blocks[0].text;
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocks;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildOpenAIConversationMessage(message: ChatMessage) {
|
|
||||||
if (message.role === "tool") {
|
|
||||||
const name = message.name?.trim() || "tool";
|
|
||||||
return {
|
|
||||||
role: "user",
|
|
||||||
content: `Tool output (${name}):\n${message.content}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const out: Record<string, unknown> = {
|
|
||||||
role: message.role,
|
|
||||||
content: toOpenAIContent(message),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (message.name && (message.role === "assistant" || message.role === "user")) {
|
|
||||||
out.name = message.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildOpenAIResponsesInputMessage(message: ChatMessage) {
|
|
||||||
if (message.role === "tool") {
|
|
||||||
const name = message.name?.trim() || "tool";
|
|
||||||
return {
|
|
||||||
role: "user",
|
|
||||||
content: `Tool output (${name}):\n${message.content}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
role: message.role,
|
|
||||||
content: toOpenAIResponsesContent(message),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSystemPromptAugmentationMessage(userLocation?: string) {
|
export function buildSystemPromptAugmentationMessage(userLocation?: string) {
|
||||||
return {
|
return {
|
||||||
role: "system",
|
role: "system",
|
||||||
@@ -219,34 +66,12 @@ export function buildSystemPromptAugmentationMessage(userLocation?: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANTHROPIC_NO_SERVER_TOOLS_PROMPT =
|
export function buildTopLevelSystemPrompt(messages: ChatMessage[], userLocation?: string, toolSystemPrompt?: string) {
|
||||||
"This Anthropic backend path does not have server-managed tool calls. Do not claim to run shell commands, Codex tasks, web searches, or fetch URLs. If the user asks for tool execution, explain that they should switch to OpenAI or xAI in this app for tool-enabled chat.";
|
return [toolSystemPrompt, buildSystemPromptAugmentation(userLocation), messages.find((message) => message.role === "system")?.content]
|
||||||
|
|
||||||
export function getAnthropicSystemPrompt(messages: ChatMessage[], userLocation?: string) {
|
|
||||||
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, buildSystemPromptAugmentation(userLocation), messages.find((message) => message.role === "system")?.content]
|
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAnthropicConversationMessage(message: ChatMessage) {
|
|
||||||
if (message.role === "system") {
|
|
||||||
throw new Error("System messages must be handled separately for Anthropic.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.role === "tool") {
|
|
||||||
const name = message.name?.trim() || "tool";
|
|
||||||
return {
|
|
||||||
role: "user",
|
|
||||||
content: `Tool output (${name}):\n${message.content}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
role: message.role === "assistant" ? "assistant" : "user",
|
|
||||||
content: toAnthropicContent(message),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildComparableAttachments(input: unknown): ChatAttachment[] {
|
export function buildComparableAttachments(input: unknown): ChatAttachment[] {
|
||||||
if (!Array.isArray(input)) return [];
|
if (!Array.isArray(input)) return [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { env } from "../env.js";
|
import {
|
||||||
import { anthropicClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
|
fetchProviderCatalogModels,
|
||||||
|
getProviderCatalogFallbackModels,
|
||||||
|
listModelCatalogProviders,
|
||||||
|
} from "./provider-adapters.js";
|
||||||
import type { Provider } from "./types.js";
|
import type { Provider } from "./types.js";
|
||||||
|
|
||||||
export type ProviderModelSnapshot = {
|
export type ProviderModelSnapshot = {
|
||||||
@@ -11,35 +14,13 @@ export type ProviderModelSnapshot = {
|
|||||||
|
|
||||||
export type ModelCatalogSnapshot = Partial<Record<Provider, ProviderModelSnapshot>>;
|
export type ModelCatalogSnapshot = Partial<Record<Provider, ProviderModelSnapshot>>;
|
||||||
|
|
||||||
const baseProviders: Provider[] = ["openai", "anthropic", "xai"];
|
|
||||||
const MODEL_FETCH_TIMEOUT_MS = 15000;
|
const MODEL_FETCH_TIMEOUT_MS = 15000;
|
||||||
const MODEL_CATALOG_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
const MODEL_CATALOG_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const modelCatalog: ModelCatalogSnapshot = {
|
const modelCatalog: ModelCatalogSnapshot = {};
|
||||||
openai: { models: [], loadedAt: null, error: null },
|
|
||||||
anthropic: { models: [], loadedAt: null, error: null },
|
|
||||||
xai: { models: [], loadedAt: null, error: null },
|
|
||||||
};
|
|
||||||
|
|
||||||
let catalogRefreshPromise: Promise<void> | null = null;
|
let catalogRefreshPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
function getCatalogProviders(): Provider[] {
|
|
||||||
return isHermesAgentConfigured() ? [...baseProviders, "hermes-agent"] : baseProviders;
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqSorted(models: string[]) {
|
|
||||||
return [...new Set(models.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLikelyOpenAIResponsesModel(model: string) {
|
|
||||||
const id = model.toLowerCase();
|
|
||||||
if (id.includes("embedding") || id.includes("moderation")) return false;
|
|
||||||
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
|
|
||||||
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
|
|
||||||
if (id.includes("search") || id.includes("computer-use")) return false;
|
|
||||||
return /^(gpt-|o\d|chatgpt-)/.test(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string) {
|
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string) {
|
||||||
let timeoutId: NodeJS.Timeout | null = null;
|
let timeoutId: NodeJS.Timeout | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -56,31 +37,9 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchProviderModels(provider: Provider) {
|
|
||||||
if (provider === "openai") {
|
|
||||||
const page = await openaiClient().models.list();
|
|
||||||
return uniqSorted(page.data.map((model) => model.id).filter(isLikelyOpenAIResponsesModel));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === "anthropic") {
|
|
||||||
const page = await anthropicClient().models.list({ limit: 200 });
|
|
||||||
return uniqSorted(page.data.map((model) => model.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === "xai") {
|
|
||||||
const page = await xaiClient().models.list();
|
|
||||||
return uniqSorted(page.data.map((model) => model.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = await hermesAgentClient().models.list();
|
|
||||||
const models = page.data.map((model) => model.id);
|
|
||||||
if (env.HERMES_AGENT_MODEL) models.push(env.HERMES_AGENT_MODEL);
|
|
||||||
return uniqSorted(models);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLogger) {
|
async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLogger) {
|
||||||
try {
|
try {
|
||||||
const models = await withTimeout(fetchProviderModels(provider), MODEL_FETCH_TIMEOUT_MS, `${provider} model fetch`);
|
const models = await withTimeout(fetchProviderCatalogModels(provider), MODEL_FETCH_TIMEOUT_MS, `${provider} model fetch`);
|
||||||
modelCatalog[provider] = {
|
modelCatalog[provider] = {
|
||||||
models,
|
models,
|
||||||
loadedAt: new Date().toISOString(),
|
loadedAt: new Date().toISOString(),
|
||||||
@@ -90,7 +49,7 @@ async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLog
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const message = err?.message ?? String(err);
|
const message = err?.message ?? String(err);
|
||||||
const previous = modelCatalog[provider];
|
const previous = modelCatalog[provider];
|
||||||
const fallbackModels = provider === "hermes-agent" && env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [];
|
const fallbackModels = getProviderCatalogFallbackModels(provider);
|
||||||
modelCatalog[provider] = {
|
modelCatalog[provider] = {
|
||||||
models: previous?.models.length ? previous.models : fallbackModels,
|
models: previous?.models.length ? previous.models : fallbackModels,
|
||||||
loadedAt: previous?.loadedAt ?? null,
|
loadedAt: previous?.loadedAt ?? null,
|
||||||
@@ -103,7 +62,7 @@ async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLog
|
|||||||
export async function refreshModelCatalog(logger?: FastifyBaseLogger) {
|
export async function refreshModelCatalog(logger?: FastifyBaseLogger) {
|
||||||
if (catalogRefreshPromise) return catalogRefreshPromise;
|
if (catalogRefreshPromise) return catalogRefreshPromise;
|
||||||
|
|
||||||
catalogRefreshPromise = Promise.all(getCatalogProviders().map((provider) => refreshProviderModels(provider, logger)))
|
catalogRefreshPromise = Promise.all(listModelCatalogProviders().map((provider) => refreshProviderModels(provider, logger)))
|
||||||
.then(() => undefined)
|
.then(() => undefined)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
catalogRefreshPromise = null;
|
catalogRefreshPromise = null;
|
||||||
@@ -129,7 +88,7 @@ export function startModelCatalogRefreshLoop(logger?: FastifyBaseLogger) {
|
|||||||
|
|
||||||
export function getModelCatalogSnapshot(): ModelCatalogSnapshot {
|
export function getModelCatalogSnapshot(): ModelCatalogSnapshot {
|
||||||
const snapshot: ModelCatalogSnapshot = {};
|
const snapshot: ModelCatalogSnapshot = {};
|
||||||
for (const provider of getCatalogProviders()) {
|
for (const provider of listModelCatalogProviders()) {
|
||||||
const entry = modelCatalog[provider] ?? { models: [], loadedAt: null, error: null };
|
const entry = modelCatalog[provider] ?? { models: [], loadedAt: null, error: null };
|
||||||
snapshot[provider] = {
|
snapshot[provider] = {
|
||||||
models: [...entry.models],
|
models: [...entry.models],
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import { prisma } from "../db.js";
|
import { prisma } from "../db.js";
|
||||||
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
import { buildToolLogMessageData } from "./chat-tools.js";
|
||||||
import { buildToolLogMessageData, normalizeEnabledChatTools, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
|
import { getProviderChatAdapter } from "./provider-adapters.js";
|
||||||
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
|
||||||
import { toPrismaProvider } from "./provider-ids.js";
|
import { toPrismaProvider } from "./provider-ids.js";
|
||||||
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
|
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
|
||||||
|
|
||||||
@@ -47,15 +46,11 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
|||||||
let usage: MultiplexResponse["usage"] | undefined;
|
let usage: MultiplexResponse["usage"] | undefined;
|
||||||
let raw: unknown;
|
let raw: unknown;
|
||||||
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
|
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
|
||||||
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
|
const adapter = getProviderChatAdapter(req.provider);
|
||||||
|
const r = await adapter.complete({
|
||||||
if (req.provider === "openai" && enabledTools.length > 0) {
|
|
||||||
const client = openaiClient();
|
|
||||||
const r = await runToolAwareOpenAIChat({
|
|
||||||
client,
|
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
messages: req.messages,
|
||||||
enabledTools,
|
enabledTools: req.enabledTools,
|
||||||
userLocation: req.userLocation,
|
userLocation: req.userLocation,
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
maxTokens: req.maxTokens,
|
maxTokens: req.maxTokens,
|
||||||
@@ -69,75 +64,6 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
|||||||
outText = r.text;
|
outText = r.text;
|
||||||
usage = r.usage;
|
usage = r.usage;
|
||||||
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
||||||
} else if (req.provider === "xai" && enabledTools.length > 0) {
|
|
||||||
const client = xaiClient();
|
|
||||||
const r = await runToolAwareChatCompletions({
|
|
||||||
client,
|
|
||||||
model: req.model,
|
|
||||||
messages: req.messages,
|
|
||||||
enabledTools,
|
|
||||||
userLocation: req.userLocation,
|
|
||||||
temperature: req.temperature,
|
|
||||||
maxTokens: req.maxTokens,
|
|
||||||
logContext: {
|
|
||||||
provider: req.provider,
|
|
||||||
model: req.model,
|
|
||||||
chatId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
raw = r.raw;
|
|
||||||
outText = r.text;
|
|
||||||
usage = r.usage;
|
|
||||||
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
|
||||||
} else if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
|
|
||||||
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
|
|
||||||
const r = await runPlainChatCompletions({
|
|
||||||
client,
|
|
||||||
model: req.model,
|
|
||||||
messages: req.messages,
|
|
||||||
userLocation: req.userLocation,
|
|
||||||
temperature: req.temperature,
|
|
||||||
maxTokens: req.maxTokens,
|
|
||||||
logContext: {
|
|
||||||
provider: req.provider,
|
|
||||||
model: req.model,
|
|
||||||
chatId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
raw = r.raw;
|
|
||||||
outText = r.text;
|
|
||||||
usage = r.usage;
|
|
||||||
} else if (req.provider === "anthropic") {
|
|
||||||
const client = anthropicClient();
|
|
||||||
|
|
||||||
const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
|
|
||||||
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
|
||||||
|
|
||||||
const r = await client.messages.create({
|
|
||||||
model: req.model,
|
|
||||||
system,
|
|
||||||
max_tokens: req.maxTokens ?? 1024,
|
|
||||||
temperature: req.temperature,
|
|
||||||
messages: msgs as any,
|
|
||||||
});
|
|
||||||
raw = r;
|
|
||||||
outText = r.content
|
|
||||||
.map((c: any) => (c.type === "text" ? c.text : ""))
|
|
||||||
.join("")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
// Anthropic usage (SDK typing varies by version)
|
|
||||||
const ru: any = (r as any).usage;
|
|
||||||
if (ru) {
|
|
||||||
usage = {
|
|
||||||
inputTokens: ru.input_tokens,
|
|
||||||
outputTokens: ru.output_tokens,
|
|
||||||
totalTokens: (ru.input_tokens ?? 0) + (ru.output_tokens ?? 0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(`unknown provider: ${req.provider}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const latencyMs = Math.round(performance.now() - t0);
|
const latencyMs = Math.round(performance.now() - t0);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import {
|
||||||
|
appendDanglingToolIntentCorrection,
|
||||||
|
buildChatToolSystemPrompt,
|
||||||
|
executeToolCallAndBuildEvent,
|
||||||
|
getEnabledChatTools,
|
||||||
|
getUnstreamedText,
|
||||||
|
looksLikeDanglingToolIntent,
|
||||||
|
MAX_DANGLING_TOOL_INTENT_RETRIES,
|
||||||
|
MAX_TOOL_ROUNDS,
|
||||||
|
mergeUsage,
|
||||||
|
normalizeModelToolCalls,
|
||||||
|
prepareToolCallExecution,
|
||||||
|
type NormalizedToolCall,
|
||||||
|
type ToolAwareCompletionParams,
|
||||||
|
type ToolAwareCompletionResult,
|
||||||
|
type ToolAwareStreamingEvent,
|
||||||
|
type ToolExecutionEvent,
|
||||||
|
} from "../chat-tools.js";
|
||||||
|
import {
|
||||||
|
buildImageSummaryText,
|
||||||
|
buildSystemPromptAugmentationMessage,
|
||||||
|
buildTextAttachmentPrompt,
|
||||||
|
getImageAttachments,
|
||||||
|
getTextAttachments,
|
||||||
|
} from "../message-content.js";
|
||||||
|
import type { ChatMessage } from "../types.js";
|
||||||
|
|
||||||
|
function toContentParts(message: ChatMessage) {
|
||||||
|
const imageAttachments = getImageAttachments(message);
|
||||||
|
const textAttachments = getTextAttachments(message);
|
||||||
|
if (!imageAttachments.length && !textAttachments.length) {
|
||||||
|
return message.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: Array<Record<string, unknown>> = [];
|
||||||
|
for (const attachment of imageAttachments) {
|
||||||
|
parts.push({
|
||||||
|
type: "image_url",
|
||||||
|
image_url: {
|
||||||
|
url: attachment.dataUrl,
|
||||||
|
detail: "auto",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||||
|
if (imageSummary) {
|
||||||
|
parts.push({ type: "text", text: imageSummary });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attachment of textAttachments) {
|
||||||
|
parts.push({ type: "text", text: buildTextAttachmentPrompt(attachment) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.content.trim()) {
|
||||||
|
parts.push({ type: "text", text: message.content });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 1 && parts[0]?.type === "text" && typeof parts[0].text === "string") {
|
||||||
|
return parts[0].text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConversationMessage(message: ChatMessage) {
|
||||||
|
if (message.role === "tool") {
|
||||||
|
const name = message.name?.trim() || "tool";
|
||||||
|
return {
|
||||||
|
role: "user",
|
||||||
|
content: `Tool output (${name}):\n${message.content}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: Record<string, unknown> = {
|
||||||
|
role: message.role,
|
||||||
|
content: toContentParts(message),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (message.name && (message.role === "assistant" || message.role === "user")) {
|
||||||
|
out.name = message.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMessages(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
||||||
|
const normalized = messages.map((message) => buildConversationMessage(message));
|
||||||
|
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlainMessages(messages: ChatMessage[], userLocation?: string) {
|
||||||
|
return [buildSystemPromptAugmentationMessage(userLocation), ...messages.map((message) => buildConversationMessage(message))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractContent(message: any) {
|
||||||
|
if (typeof message?.content === "string") return message.content;
|
||||||
|
if (!Array.isArray(message?.content)) return "";
|
||||||
|
|
||||||
|
return message.content
|
||||||
|
.map((part: any) => {
|
||||||
|
if (typeof part === "string") return part;
|
||||||
|
if (typeof part?.text === "string") return part.text;
|
||||||
|
if (typeof part?.content === "string") return part.content;
|
||||||
|
return "";
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeWithChatCompletionsApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||||
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
if (!enabledTools.length) {
|
||||||
|
const completion = await params.client.chat.completions.create({
|
||||||
|
model: params.model,
|
||||||
|
messages: normalizePlainMessages(params.messages, params.userLocation),
|
||||||
|
temperature: params.temperature,
|
||||||
|
max_tokens: params.maxTokens,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const usageAcc: Required<NonNullable<ToolAwareCompletionResult["usage"]>> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
const sawUsage = mergeUsage(usageAcc, completion?.usage);
|
||||||
|
const message = completion?.choices?.[0]?.message;
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: extractContent(message),
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { response: completion, api: "chat.completions" },
|
||||||
|
toolEvents: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation: any[] = normalizeMessages(params.messages, params.userLocation, params);
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
|
const usageAcc: Required<NonNullable<ToolAwareCompletionResult["usage"]>> = { 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 completion = await params.client.chat.completions.create({
|
||||||
|
model: params.model,
|
||||||
|
messages: conversation,
|
||||||
|
temperature: params.temperature,
|
||||||
|
max_tokens: params.maxTokens,
|
||||||
|
tools: enabledTools,
|
||||||
|
tool_choice: "auto",
|
||||||
|
} as any);
|
||||||
|
rawResponses.push(completion);
|
||||||
|
sawUsage = mergeUsage(usageAcc, completion?.usage) || sawUsage;
|
||||||
|
|
||||||
|
const message = completion?.choices?.[0]?.message;
|
||||||
|
if (!message) {
|
||||||
|
return {
|
||||||
|
text: "",
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, missingMessage: true },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||||
|
if (!toolCalls.length) {
|
||||||
|
const text = typeof message.content === "string" ? message.content : "";
|
||||||
|
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||||
|
danglingToolIntentRetries += 1;
|
||||||
|
appendDanglingToolIntentCorrection(conversation, text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedToolCalls = normalizeModelToolCalls(toolCalls, round);
|
||||||
|
totalToolCalls += normalizedToolCalls.length;
|
||||||
|
|
||||||
|
const assistantToolCallMessage: any = {
|
||||||
|
role: "assistant",
|
||||||
|
tool_calls: normalizedToolCalls.map((call) => ({
|
||||||
|
id: call.id,
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: call.name,
|
||||||
|
arguments: call.arguments,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
if (typeof message.content === "string" && message.content.length) {
|
||||||
|
assistantToolCallMessage.content = message.content;
|
||||||
|
}
|
||||||
|
conversation.push(assistantToolCallMessage);
|
||||||
|
|
||||||
|
for (const call of normalizedToolCalls) {
|
||||||
|
const { execution } = prepareToolCallExecution(call);
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
|
toolEvents.push(event);
|
||||||
|
|
||||||
|
conversation.push({
|
||||||
|
role: "tool",
|
||||||
|
tool_call_id: call.id,
|
||||||
|
content: JSON.stringify(toolResult),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function* streamWithChatCompletionsApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||||
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
if (!enabledTools.length) {
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const usageAcc: Required<NonNullable<ToolAwareCompletionResult["usage"]>> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
let sawUsage = false;
|
||||||
|
let text = "";
|
||||||
|
|
||||||
|
const stream = await params.client.chat.completions.create({
|
||||||
|
model: params.model,
|
||||||
|
messages: normalizePlainMessages(params.messages, params.userLocation),
|
||||||
|
temperature: params.temperature,
|
||||||
|
max_tokens: params.maxTokens,
|
||||||
|
stream: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
for await (const chunk of stream as any as AsyncIterable<any>) {
|
||||||
|
rawResponses.push(chunk);
|
||||||
|
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
|
||||||
|
|
||||||
|
const deltaText = chunk?.choices?.[0]?.delta?.content ?? "";
|
||||||
|
if (typeof deltaText === "string" && deltaText.length) {
|
||||||
|
text += deltaText;
|
||||||
|
yield { type: "delta", text: deltaText };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, api: "chat.completions" },
|
||||||
|
toolEvents: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation: any[] = normalizeMessages(params.messages, params.userLocation, params);
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
|
const usageAcc: Required<NonNullable<ToolAwareCompletionResult["usage"]>> = { 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 stream = await params.client.chat.completions.create({
|
||||||
|
model: params.model,
|
||||||
|
messages: conversation,
|
||||||
|
temperature: params.temperature,
|
||||||
|
max_tokens: params.maxTokens,
|
||||||
|
tools: enabledTools,
|
||||||
|
tool_choice: "auto",
|
||||||
|
stream: true,
|
||||||
|
stream_options: { include_usage: true },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
let roundText = "";
|
||||||
|
let streamedRoundText = "";
|
||||||
|
let roundHasToolCalls = false;
|
||||||
|
const roundToolCalls = new Map<number, { id?: string; name?: string; arguments: string }>();
|
||||||
|
|
||||||
|
for await (const chunk of stream as any as AsyncIterable<any>) {
|
||||||
|
rawResponses.push(chunk);
|
||||||
|
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
|
||||||
|
|
||||||
|
const choice = chunk?.choices?.[0];
|
||||||
|
const deltaText = choice?.delta?.content ?? "";
|
||||||
|
if (typeof deltaText === "string" && deltaText.length) {
|
||||||
|
roundText += deltaText;
|
||||||
|
if (!roundHasToolCalls) {
|
||||||
|
streamedRoundText += deltaText;
|
||||||
|
yield { type: "delta", text: deltaText };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaToolCalls = Array.isArray(choice?.delta?.tool_calls) ? choice.delta.tool_calls : [];
|
||||||
|
if (deltaToolCalls.length) {
|
||||||
|
roundHasToolCalls = true;
|
||||||
|
}
|
||||||
|
for (const toolCall of deltaToolCalls) {
|
||||||
|
const idx = typeof toolCall?.index === "number" ? toolCall.index : 0;
|
||||||
|
const entry = roundToolCalls.get(idx) ?? { arguments: "" };
|
||||||
|
if (typeof toolCall?.id === "string" && toolCall.id.length) {
|
||||||
|
entry.id = toolCall.id;
|
||||||
|
}
|
||||||
|
if (typeof toolCall?.function?.name === "string" && toolCall.function.name.length) {
|
||||||
|
entry.name = toolCall.function.name;
|
||||||
|
}
|
||||||
|
if (typeof toolCall?.function?.arguments === "string" && toolCall.function.arguments.length) {
|
||||||
|
entry.arguments += toolCall.function.arguments;
|
||||||
|
}
|
||||||
|
roundToolCalls.set(idx, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedToolCalls: NormalizedToolCall[] = [...roundToolCalls.entries()]
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
.map(([_, call], index) => ({
|
||||||
|
id: call.id ?? `tool_call_${round}_${index}`,
|
||||||
|
name: call.name ?? "unknown_tool",
|
||||||
|
arguments: call.arguments || "{}",
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!normalizedToolCalls.length) {
|
||||||
|
if (!streamedRoundText && danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(roundText)) {
|
||||||
|
danglingToolIntentRetries += 1;
|
||||||
|
appendDanglingToolIntentCorrection(conversation, roundText);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const unstreamedText = getUnstreamedText(roundText, streamedRoundText);
|
||||||
|
if (unstreamedText) {
|
||||||
|
yield { type: "delta", text: unstreamedText };
|
||||||
|
}
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text: roundText,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls },
|
||||||
|
toolEvents,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalToolCalls += normalizedToolCalls.length;
|
||||||
|
const assistantToolCallMessage: any = {
|
||||||
|
role: "assistant",
|
||||||
|
tool_calls: normalizedToolCalls.map((call) => ({
|
||||||
|
id: call.id,
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: call.name,
|
||||||
|
arguments: call.arguments,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
if (roundText) {
|
||||||
|
assistantToolCallMessage.content = roundText;
|
||||||
|
}
|
||||||
|
conversation.push(assistantToolCallMessage);
|
||||||
|
|
||||||
|
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 };
|
||||||
|
conversation.push({
|
||||||
|
role: "tool",
|
||||||
|
tool_call_id: call.id,
|
||||||
|
content: JSON.stringify(toolResult),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
toolEvents,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
import {
|
||||||
|
buildChatToolSystemPrompt,
|
||||||
|
executeToolCallAndBuildEvent,
|
||||||
|
getEnabledChatTools,
|
||||||
|
looksLikeDanglingToolIntent,
|
||||||
|
MAX_DANGLING_TOOL_INTENT_RETRIES,
|
||||||
|
MAX_TOOL_ROUNDS,
|
||||||
|
parseToolArgs,
|
||||||
|
prepareToolCallExecution,
|
||||||
|
type NormalizedToolCall,
|
||||||
|
type ToolAwareCompletionParams,
|
||||||
|
type ToolAwareCompletionResult,
|
||||||
|
type ToolAwareStreamingEvent,
|
||||||
|
type ToolAwareUsage,
|
||||||
|
type ToolExecutionEvent,
|
||||||
|
type ToolRunOutcome,
|
||||||
|
} from "../chat-tools.js";
|
||||||
|
import {
|
||||||
|
buildImageSummaryText,
|
||||||
|
buildTextAttachmentPrompt,
|
||||||
|
buildTopLevelSystemPrompt,
|
||||||
|
getImageAttachments,
|
||||||
|
getTextAttachments,
|
||||||
|
parseImageDataUrl,
|
||||||
|
} from "../message-content.js";
|
||||||
|
import type { ChatMessage } from "../types.js";
|
||||||
|
|
||||||
|
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.";
|
||||||
|
|
||||||
|
const DEFAULT_ANTHROPIC_MAX_TOKENS = 128_000;
|
||||||
|
const MODEL_MAX_TOKENS_CACHE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const modelMaxTokensCache = new Map<string, { maxTokens: number; expiresAt: number }>();
|
||||||
|
|
||||||
|
function readMaxTokens(value: unknown) {
|
||||||
|
return Number.isSafeInteger(value) && (value as number) > 0 ? (value as number) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelInfoMaxTokens(modelInfo: any) {
|
||||||
|
return readMaxTokens(modelInfo?.max_tokens) ?? readMaxTokens(modelInfo?.maxTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMessagesMaxTokens(params: ToolAwareCompletionParams) {
|
||||||
|
if (params.maxTokens) return params.maxTokens;
|
||||||
|
|
||||||
|
const cached = modelMaxTokensCache.get(params.model);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) return cached.maxTokens;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const retrieve = params.client?.models?.retrieve;
|
||||||
|
if (typeof retrieve === "function") {
|
||||||
|
const modelInfo = await retrieve.call(params.client.models, params.model);
|
||||||
|
const maxTokens = getModelInfoMaxTokens(modelInfo);
|
||||||
|
if (maxTokens) {
|
||||||
|
modelMaxTokensCache.set(params.model, {
|
||||||
|
maxTokens,
|
||||||
|
expiresAt: Date.now() + MODEL_MAX_TOKENS_CACHE_MS,
|
||||||
|
});
|
||||||
|
return maxTokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to the documented max for Claude Opus 4.8 and related high-output models.
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_ANTHROPIC_MAX_TOKENS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTools(tools: any[]) {
|
||||||
|
return tools
|
||||||
|
.map((tool) => {
|
||||||
|
if (tool?.type !== "function") return null;
|
||||||
|
return {
|
||||||
|
name: tool.function.name,
|
||||||
|
description: tool.function.description,
|
||||||
|
input_schema: tool.function.parameters,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toContentBlocks(message: ChatMessage) {
|
||||||
|
const imageAttachments = getImageAttachments(message);
|
||||||
|
const textAttachments = getTextAttachments(message);
|
||||||
|
if (!imageAttachments.length && !textAttachments.length) {
|
||||||
|
return message.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks: Array<Record<string, unknown>> = [];
|
||||||
|
for (const attachment of imageAttachments) {
|
||||||
|
const source = parseImageDataUrl(attachment);
|
||||||
|
blocks.push({
|
||||||
|
type: "image",
|
||||||
|
source: {
|
||||||
|
type: "base64",
|
||||||
|
media_type: source.mediaType,
|
||||||
|
data: source.data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||||
|
if (imageSummary) {
|
||||||
|
blocks.push({ type: "text", text: imageSummary });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attachment of textAttachments) {
|
||||||
|
blocks.push({ type: "text", text: buildTextAttachmentPrompt(attachment) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.content.trim()) {
|
||||||
|
blocks.push({ type: "text", text: message.content });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blocks.length === 1 && blocks[0]?.type === "text" && typeof blocks[0].text === "string") {
|
||||||
|
return blocks[0].text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConversationMessage(message: ChatMessage) {
|
||||||
|
if (message.role === "system") {
|
||||||
|
throw new Error("System messages must be handled separately for top-level-system protocols.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.role === "tool") {
|
||||||
|
const name = message.name?.trim() || "tool";
|
||||||
|
return {
|
||||||
|
role: "user",
|
||||||
|
content: `Tool output (${name}):\n${message.content}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: message.role === "assistant" ? "assistant" : "user",
|
||||||
|
content: toContentBlocks(message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBaseMessages(params: ToolAwareCompletionParams) {
|
||||||
|
return params.messages.filter((message) => message.role !== "system").map((message) => buildConversationMessage(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyToolInput(input: unknown) {
|
||||||
|
if (typeof input === "string") return input;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(input ?? {});
|
||||||
|
} catch {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolCalls(content: any[], round: number): NormalizedToolCall[] {
|
||||||
|
return content
|
||||||
|
.filter((item) => item?.type === "tool_use")
|
||||||
|
.map((call: any, index: number) => ({
|
||||||
|
id: call?.id ?? `tool_call_${round}_${index}`,
|
||||||
|
name: call?.name ?? "unknown_tool",
|
||||||
|
arguments: stringifyToolInput(call?.input),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractText(response: any) {
|
||||||
|
if (!Array.isArray(response?.content)) return "";
|
||||||
|
return response.content
|
||||||
|
.map((content: any) => (content?.type === "text" && typeof content.text === "string" ? content.text : ""))
|
||||||
|
.join("")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToolResultBlock(call: NormalizedToolCall, toolResult: ToolRunOutcome) {
|
||||||
|
return {
|
||||||
|
type: "tool_result",
|
||||||
|
tool_use_id: call.id,
|
||||||
|
content: JSON.stringify(toolResult),
|
||||||
|
is_error: !toolResult.ok,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendCorrection(conversation: any[], text: string) {
|
||||||
|
conversation.push({ role: "assistant", content: text });
|
||||||
|
conversation.push({
|
||||||
|
role: "user",
|
||||||
|
content: INTERNAL_CORRECTION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||||
|
if (!usage) return false;
|
||||||
|
const inputTokens = usage.input_tokens ?? 0;
|
||||||
|
const outputTokens = usage.output_tokens ?? 0;
|
||||||
|
acc.inputTokens += inputTokens;
|
||||||
|
acc.outputTokens += outputTokens;
|
||||||
|
acc.totalTokens += inputTokens + outputTokens;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeWithMessagesApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||||
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const maxTokens = await getMessagesMaxTokens(params);
|
||||||
|
if (!enabledTools.length) {
|
||||||
|
const response = await params.client.messages.create({
|
||||||
|
model: params.model,
|
||||||
|
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
temperature: params.temperature,
|
||||||
|
messages: buildBaseMessages(params),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
const sawUsage = mergeUsage(usageAcc, response?.usage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: extractText(response),
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { response, api: "messages" },
|
||||||
|
toolEvents: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation: any[] = buildBaseMessages(params);
|
||||||
|
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 params.client.messages.create({
|
||||||
|
model: params.model,
|
||||||
|
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
temperature: params.temperature,
|
||||||
|
messages: conversation,
|
||||||
|
tools: toTools(enabledTools),
|
||||||
|
tool_choice: { type: "auto" },
|
||||||
|
} as any);
|
||||||
|
rawResponses.push(response);
|
||||||
|
sawUsage = mergeUsage(usageAcc, response?.usage) || sawUsage;
|
||||||
|
|
||||||
|
const content = Array.isArray(response?.content) ? response.content : [];
|
||||||
|
const normalizedToolCalls = normalizeToolCalls(content, round);
|
||||||
|
if (!normalizedToolCalls.length) {
|
||||||
|
const text = extractText(response);
|
||||||
|
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: "messages" },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
totalToolCalls += normalizedToolCalls.length;
|
||||||
|
conversation.push({
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toolResultBlocks: any[] = [];
|
||||||
|
for (const call of normalizedToolCalls) {
|
||||||
|
const { execution } = prepareToolCallExecution(call);
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
|
toolEvents.push(event);
|
||||||
|
toolResultBlocks.push(buildToolResultBlock(call, toolResult));
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation.push({
|
||||||
|
role: "user",
|
||||||
|
content: toolResultBlocks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "messages" },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function* streamWithMessagesApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||||
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const maxTokens = await getMessagesMaxTokens(params);
|
||||||
|
if (!enabledTools.length) {
|
||||||
|
const rawResponses: unknown[] = [];
|
||||||
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
|
let sawUsage = false;
|
||||||
|
let roundInputTokens = 0;
|
||||||
|
let roundOutputTokens = 0;
|
||||||
|
let text = "";
|
||||||
|
|
||||||
|
const stream = await params.client.messages.create({
|
||||||
|
model: params.model,
|
||||||
|
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
temperature: params.temperature,
|
||||||
|
messages: buildBaseMessages(params),
|
||||||
|
stream: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
for await (const ev of stream as any as AsyncIterable<any>) {
|
||||||
|
rawResponses.push(ev);
|
||||||
|
if (ev?.type === "message_start" && ev?.message?.usage) {
|
||||||
|
roundInputTokens = ev.message.usage.input_tokens ?? roundInputTokens;
|
||||||
|
sawUsage = true;
|
||||||
|
}
|
||||||
|
if (ev?.type === "content_block_delta" && ev?.delta?.type === "text_delta") {
|
||||||
|
const delta = ev.delta.text ?? "";
|
||||||
|
if (delta) {
|
||||||
|
text += delta;
|
||||||
|
yield { type: "delta", text: delta };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ev?.type === "message_delta" && ev.usage) {
|
||||||
|
roundInputTokens = ev.usage.input_tokens ?? roundInputTokens;
|
||||||
|
roundOutputTokens = ev.usage.output_tokens ?? roundOutputTokens;
|
||||||
|
sawUsage = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sawUsage) {
|
||||||
|
usageAcc.inputTokens += roundInputTokens;
|
||||||
|
usageAcc.outputTokens += roundOutputTokens;
|
||||||
|
usageAcc.totalTokens += roundInputTokens + roundOutputTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, toolCallsUsed: 0, api: "messages" },
|
||||||
|
toolEvents: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation: any[] = buildBaseMessages(params);
|
||||||
|
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 stream = await params.client.messages.create({
|
||||||
|
model: params.model,
|
||||||
|
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
temperature: params.temperature,
|
||||||
|
messages: conversation,
|
||||||
|
tools: toTools(enabledTools),
|
||||||
|
tool_choice: { type: "auto" },
|
||||||
|
stream: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const contentByIndex = new Map<number, any>();
|
||||||
|
const toolArgumentByIndex = new Map<number, string>();
|
||||||
|
let roundText = "";
|
||||||
|
let roundHasToolCalls = false;
|
||||||
|
let roundInputTokens = 0;
|
||||||
|
let roundOutputTokens = 0;
|
||||||
|
let sawRoundUsage = false;
|
||||||
|
|
||||||
|
for await (const ev of stream as any as AsyncIterable<any>) {
|
||||||
|
rawResponses.push(ev);
|
||||||
|
|
||||||
|
if (ev?.type === "message_start" && ev?.message?.usage) {
|
||||||
|
roundInputTokens = ev.message.usage.input_tokens ?? roundInputTokens;
|
||||||
|
sawRoundUsage = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev?.type === "content_block_start" && typeof ev.index === "number") {
|
||||||
|
const block = ev.content_block ?? {};
|
||||||
|
if (block.type === "tool_use") {
|
||||||
|
roundHasToolCalls = true;
|
||||||
|
contentByIndex.set(ev.index, {
|
||||||
|
type: "tool_use",
|
||||||
|
id: block.id,
|
||||||
|
name: block.name,
|
||||||
|
input: block.input ?? {},
|
||||||
|
});
|
||||||
|
toolArgumentByIndex.set(ev.index, "");
|
||||||
|
} else if (block.type === "text") {
|
||||||
|
contentByIndex.set(ev.index, {
|
||||||
|
type: "text",
|
||||||
|
text: typeof block.text === "string" ? block.text : "",
|
||||||
|
});
|
||||||
|
} else if (block.type) {
|
||||||
|
contentByIndex.set(ev.index, block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev?.type === "content_block_delta" && typeof ev.index === "number") {
|
||||||
|
if (ev.delta?.type === "text_delta") {
|
||||||
|
const delta = typeof ev.delta.text === "string" ? ev.delta.text : "";
|
||||||
|
if (delta) {
|
||||||
|
const block = contentByIndex.get(ev.index) ?? { type: "text", text: "" };
|
||||||
|
if (block.type === "text") {
|
||||||
|
block.text = `${typeof block.text === "string" ? block.text : ""}${delta}`;
|
||||||
|
contentByIndex.set(ev.index, block);
|
||||||
|
}
|
||||||
|
roundText += delta;
|
||||||
|
}
|
||||||
|
} else if (ev.delta?.type === "input_json_delta") {
|
||||||
|
roundHasToolCalls = true;
|
||||||
|
const partialJson = typeof ev.delta.partial_json === "string" ? ev.delta.partial_json : "";
|
||||||
|
toolArgumentByIndex.set(ev.index, `${toolArgumentByIndex.get(ev.index) ?? ""}${partialJson}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev?.type === "content_block_stop" && typeof ev.index === "number") {
|
||||||
|
const block = contentByIndex.get(ev.index);
|
||||||
|
if (block?.type === "tool_use") {
|
||||||
|
const rawArguments = toolArgumentByIndex.get(ev.index) || stringifyToolInput(block.input);
|
||||||
|
try {
|
||||||
|
block.input = parseToolArgs(rawArguments);
|
||||||
|
} catch {
|
||||||
|
block.input = {};
|
||||||
|
}
|
||||||
|
contentByIndex.set(ev.index, block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev?.type === "message_delta" && ev.usage) {
|
||||||
|
roundInputTokens = ev.usage.input_tokens ?? roundInputTokens;
|
||||||
|
roundOutputTokens = ev.usage.output_tokens ?? roundOutputTokens;
|
||||||
|
sawRoundUsage = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sawRoundUsage) {
|
||||||
|
usageAcc.inputTokens += roundInputTokens;
|
||||||
|
usageAcc.outputTokens += roundOutputTokens;
|
||||||
|
usageAcc.totalTokens += roundInputTokens + roundOutputTokens;
|
||||||
|
sawUsage = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexedContent = [...contentByIndex.entries()].sort((a, b) => a[0] - b[0]);
|
||||||
|
const assistantContent = indexedContent.map(([, block]) => block);
|
||||||
|
const normalizedToolCalls: NormalizedToolCall[] = indexedContent
|
||||||
|
.filter(([, block]) => block?.type === "tool_use")
|
||||||
|
.map(([index, block], callIndex) => ({
|
||||||
|
id: block.id ?? `tool_call_${round}_${callIndex}`,
|
||||||
|
name: block.name ?? "unknown_tool",
|
||||||
|
arguments: toolArgumentByIndex.get(index) || stringifyToolInput(block.input),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!normalizedToolCalls.length) {
|
||||||
|
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(roundText)) {
|
||||||
|
danglingToolIntentRetries += 1;
|
||||||
|
appendCorrection(conversation, roundText);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (roundText) {
|
||||||
|
yield { type: "delta", text: roundText };
|
||||||
|
}
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text: roundText,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "messages" },
|
||||||
|
toolEvents,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalToolCalls += normalizedToolCalls.length;
|
||||||
|
conversation.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: assistantContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toolResultBlocks: 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 };
|
||||||
|
toolResultBlocks.push(buildToolResultBlock(call, toolResult));
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation.push({
|
||||||
|
role: "user",
|
||||||
|
content: toolResultBlocks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "messages" },
|
||||||
|
toolEvents,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import {
|
||||||
|
appendDanglingToolIntentCorrection,
|
||||||
|
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,
|
||||||
|
buildSystemPromptAugmentationMessage,
|
||||||
|
buildTextAttachmentPrompt,
|
||||||
|
getImageAttachments,
|
||||||
|
getTextAttachments,
|
||||||
|
} from "../message-content.js";
|
||||||
|
import type { ChatMessage } from "../types.js";
|
||||||
|
|
||||||
|
function toResponsesTools(tools: any[]) {
|
||||||
|
return tools.map((tool) => {
|
||||||
|
if (tool?.type !== "function") return tool;
|
||||||
|
return {
|
||||||
|
type: "function",
|
||||||
|
name: tool.function.name,
|
||||||
|
description: tool.function.description,
|
||||||
|
parameters: tool.function.parameters,
|
||||||
|
strict: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toContentParts(message: ChatMessage) {
|
||||||
|
const imageAttachments = getImageAttachments(message);
|
||||||
|
const textAttachments = getTextAttachments(message);
|
||||||
|
if (!imageAttachments.length && !textAttachments.length) {
|
||||||
|
return message.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: Array<Record<string, unknown>> = [];
|
||||||
|
for (const attachment of imageAttachments) {
|
||||||
|
parts.push({
|
||||||
|
type: "input_image",
|
||||||
|
image_url: attachment.dataUrl,
|
||||||
|
detail: "auto",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||||
|
if (imageSummary) {
|
||||||
|
parts.push({ type: "input_text", text: imageSummary });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attachment of textAttachments) {
|
||||||
|
parts.push({ type: "input_text", text: buildTextAttachmentPrompt(attachment) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.content.trim()) {
|
||||||
|
parts.push({ type: "input_text", text: message.content });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 1 && parts[0]?.type === "input_text" && typeof parts[0].text === "string") {
|
||||||
|
return parts[0].text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInputMessage(message: ChatMessage) {
|
||||||
|
if (message.role === "tool") {
|
||||||
|
const name = message.name?.trim() || "tool";
|
||||||
|
return {
|
||||||
|
role: "user",
|
||||||
|
content: `Tool output (${name}):\n${message.content}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: message.role,
|
||||||
|
content: toContentParts(message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInput(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
||||||
|
const normalized = messages.map((message) => buildInputMessage(message));
|
||||||
|
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||||
|
if (!usage) return false;
|
||||||
|
acc.inputTokens += usage.input_tokens ?? 0;
|
||||||
|
acc.outputTokens += usage.output_tokens ?? 0;
|
||||||
|
acc.totalTokens += usage.total_tokens ?? 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOutputItems(response: any) {
|
||||||
|
return Array.isArray(response?.output) ? response.output : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractText(response: any, fallback = "") {
|
||||||
|
if (typeof response?.output_text === "string") return response.output_text;
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const item of getOutputItems(response)) {
|
||||||
|
if (item?.type !== "message" || !Array.isArray(item.content)) continue;
|
||||||
|
for (const content of item.content) {
|
||||||
|
if (content?.type === "output_text" && typeof content.text === "string") {
|
||||||
|
parts.push(content.text);
|
||||||
|
} else if (content?.type === "refusal" && typeof content.refusal === "string") {
|
||||||
|
parts.push(content.refusal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join("") || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFailureMessage(response: any) {
|
||||||
|
if (response?.status !== "failed" && response?.status !== "incomplete") return null;
|
||||||
|
const errorMessage = typeof response?.error?.message === "string" ? response.error.message : null;
|
||||||
|
const incompleteReason = typeof response?.incomplete_details?.reason === "string" ? response.incomplete_details.reason : null;
|
||||||
|
return errorMessage ?? (incompleteReason ? `Response incomplete: ${incompleteReason}` : `Response ${response.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolCalls(outputItems: any[], round: number): NormalizedToolCall[] {
|
||||||
|
return outputItems
|
||||||
|
.filter((item) => item?.type === "function_call")
|
||||||
|
.map((call: any, index: number) => ({
|
||||||
|
id: call.call_id ?? call.id ?? `tool_call_${round}_${index}`,
|
||||||
|
name: call.name ?? "unknown_tool",
|
||||||
|
arguments: call.arguments ?? "{}",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeWithResponsesApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||||
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const input: any[] = normalizeInput(params.messages, params.userLocation, params);
|
||||||
|
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 params.client.responses.create({
|
||||||
|
model: params.model,
|
||||||
|
input,
|
||||||
|
temperature: params.temperature,
|
||||||
|
max_output_tokens: params.maxTokens,
|
||||||
|
tools: toResponsesTools(enabledTools),
|
||||||
|
tool_choice: "auto",
|
||||||
|
parallel_tool_calls: true,
|
||||||
|
store: true,
|
||||||
|
} as any);
|
||||||
|
rawResponses.push(response);
|
||||||
|
sawUsage = mergeUsage(usageAcc, response?.usage) || sawUsage;
|
||||||
|
|
||||||
|
const failureMessage = getFailureMessage(response);
|
||||||
|
if (failureMessage) {
|
||||||
|
throw new Error(failureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputItems = getOutputItems(response);
|
||||||
|
const normalizedToolCalls = normalizeToolCalls(outputItems, round);
|
||||||
|
if (!normalizedToolCalls.length) {
|
||||||
|
const text = extractText(response);
|
||||||
|
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||||
|
danglingToolIntentRetries += 1;
|
||||||
|
appendDanglingToolIntentCorrection(input, text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, api: "responses" },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
totalToolCalls += normalizedToolCalls.length;
|
||||||
|
input.push(...outputItems);
|
||||||
|
|
||||||
|
for (const call of normalizedToolCalls) {
|
||||||
|
const { execution } = prepareToolCallExecution(call);
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
|
toolEvents.push(event);
|
||||||
|
|
||||||
|
input.push({
|
||||||
|
type: "function_call_output",
|
||||||
|
call_id: call.id,
|
||||||
|
output: JSON.stringify(toolResult),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "responses" },
|
||||||
|
toolEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function* streamWithResponsesApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||||
|
const enabledTools = getEnabledChatTools(params);
|
||||||
|
const input: any[] = normalizeInput(params.messages, params.userLocation, params);
|
||||||
|
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 stream = await params.client.responses.create({
|
||||||
|
model: params.model,
|
||||||
|
input,
|
||||||
|
temperature: params.temperature,
|
||||||
|
max_output_tokens: params.maxTokens,
|
||||||
|
tools: toResponsesTools(enabledTools),
|
||||||
|
tool_choice: "auto",
|
||||||
|
parallel_tool_calls: true,
|
||||||
|
store: true,
|
||||||
|
stream: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
let roundText = "";
|
||||||
|
let streamedRoundText = "";
|
||||||
|
let roundHasToolCalls = false;
|
||||||
|
let canStreamRoundText = false;
|
||||||
|
let completedResponse: any | null = null;
|
||||||
|
const completedOutputItems: any[] = [];
|
||||||
|
|
||||||
|
for await (const event of stream as any as AsyncIterable<any>) {
|
||||||
|
rawResponses.push(event);
|
||||||
|
|
||||||
|
if (event?.type === "response.output_text.delta" && typeof event.delta === "string") {
|
||||||
|
roundText += event.delta;
|
||||||
|
if (canStreamRoundText && !roundHasToolCalls && event.delta.length) {
|
||||||
|
streamedRoundText += event.delta;
|
||||||
|
yield { type: "delta", text: event.delta };
|
||||||
|
}
|
||||||
|
} else if (event?.type === "response.output_item.added" && event.item) {
|
||||||
|
if (event.item.type === "function_call") {
|
||||||
|
roundHasToolCalls = true;
|
||||||
|
canStreamRoundText = false;
|
||||||
|
} else if (event.item.type === "message" && !roundHasToolCalls) {
|
||||||
|
canStreamRoundText = true;
|
||||||
|
}
|
||||||
|
} else if (event?.type === "response.output_item.done" && event.item) {
|
||||||
|
completedOutputItems[event.output_index ?? completedOutputItems.length] = event.item;
|
||||||
|
if (event.item.type === "function_call") {
|
||||||
|
roundHasToolCalls = true;
|
||||||
|
canStreamRoundText = false;
|
||||||
|
}
|
||||||
|
} else if (event?.type === "response.completed") {
|
||||||
|
completedResponse = event.response;
|
||||||
|
sawUsage = mergeUsage(usageAcc, event.response?.usage) || sawUsage;
|
||||||
|
} else if (event?.type === "response.failed" || event?.type === "response.incomplete") {
|
||||||
|
completedResponse = event.response;
|
||||||
|
sawUsage = mergeUsage(usageAcc, event.response?.usage) || sawUsage;
|
||||||
|
} else if (event?.type === "error") {
|
||||||
|
throw new Error(event.message ?? "Responses stream failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const failureMessage = getFailureMessage(completedResponse);
|
||||||
|
if (failureMessage) {
|
||||||
|
throw new Error(failureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputItems = getOutputItems(completedResponse);
|
||||||
|
const responseOutputItems = outputItems.length ? outputItems : completedOutputItems.filter(Boolean);
|
||||||
|
const normalizedToolCalls = normalizeToolCalls(responseOutputItems, round);
|
||||||
|
if (!normalizedToolCalls.length) {
|
||||||
|
const text = extractText(completedResponse, roundText);
|
||||||
|
if (!streamedRoundText && danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||||
|
danglingToolIntentRetries += 1;
|
||||||
|
appendDanglingToolIntentCorrection(input, text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const unstreamedText = getUnstreamedText(text, streamedRoundText);
|
||||||
|
if (unstreamedText) {
|
||||||
|
yield { type: "delta", text: unstreamedText };
|
||||||
|
}
|
||||||
|
yield {
|
||||||
|
type: "done",
|
||||||
|
result: {
|
||||||
|
text,
|
||||||
|
usage: sawUsage ? usageAcc : undefined,
|
||||||
|
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "responses" },
|
||||||
|
toolEvents,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalToolCalls += normalizedToolCalls.length;
|
||||||
|
input.push(...responseOutputItems);
|
||||||
|
|
||||||
|
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 };
|
||||||
|
input.push({
|
||||||
|
type: "function_call_output",
|
||||||
|
call_id: call.id,
|
||||||
|
output: JSON.stringify(toolResult),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "responses" },
|
||||||
|
toolEvents,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import {
|
||||||
|
normalizeEnabledChatTools,
|
||||||
|
type ToolAwareCompletionParams,
|
||||||
|
type ToolAwareCompletionResult,
|
||||||
|
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, geminiClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
|
||||||
|
import type { ChatMessage, Provider } from "./types.js";
|
||||||
|
|
||||||
|
type ProviderAdapterParams = {
|
||||||
|
model: string;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
enabledTools?: string[];
|
||||||
|
userLocation?: string;
|
||||||
|
temperature?: number;
|
||||||
|
maxTokens?: number;
|
||||||
|
logContext?: ToolAwareCompletionParams["logContext"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderChatAdapter = {
|
||||||
|
provider: Provider;
|
||||||
|
complete(params: ProviderAdapterParams): Promise<ToolAwareCompletionResult>;
|
||||||
|
stream(params: ProviderAdapterParams): AsyncGenerator<ToolAwareStreamingEvent>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChatProtocolId = "chat-completions" | "gemini" | "messages" | "responses";
|
||||||
|
|
||||||
|
type ChatProtocol = {
|
||||||
|
id: ChatProtocolId;
|
||||||
|
complete(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult>;
|
||||||
|
stream(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ModelCatalogSpec = {
|
||||||
|
enabled?: () => boolean;
|
||||||
|
fetchModels(client: any): Promise<string[]>;
|
||||||
|
fallbackModels?: () => string[];
|
||||||
|
sortModels?: (models: string[]) => string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderBackendSpec = {
|
||||||
|
createClient: () => any;
|
||||||
|
plainProtocol: ChatProtocol;
|
||||||
|
toolProtocol?: ChatProtocol;
|
||||||
|
managedTools?: boolean;
|
||||||
|
modelCatalog?: ModelCatalogSpec;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chatCompletionsProtocol: ChatProtocol = {
|
||||||
|
id: "chat-completions",
|
||||||
|
complete: completeWithChatCompletionsApi,
|
||||||
|
stream: streamWithChatCompletionsApi,
|
||||||
|
};
|
||||||
|
|
||||||
|
const messagesProtocol: ChatProtocol = {
|
||||||
|
id: "messages",
|
||||||
|
complete: completeWithMessagesApi,
|
||||||
|
stream: streamWithMessagesApi,
|
||||||
|
};
|
||||||
|
|
||||||
|
const geminiProtocol: ChatProtocol = {
|
||||||
|
id: "gemini",
|
||||||
|
complete: completeWithGeminiApi,
|
||||||
|
stream: streamWithGeminiApi,
|
||||||
|
};
|
||||||
|
|
||||||
|
const responsesProtocol: ChatProtocol = {
|
||||||
|
id: "responses",
|
||||||
|
complete: completeWithResponsesApi,
|
||||||
|
stream: streamWithResponsesApi,
|
||||||
|
};
|
||||||
|
|
||||||
|
function uniqSorted(values: string[]) {
|
||||||
|
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelIdsFromListResponse(page: any) {
|
||||||
|
return Array.isArray(page?.data)
|
||||||
|
? page.data.map((model: any) => model?.id).filter((id: unknown): id is string => typeof id === "string")
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
|
||||||
|
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
|
||||||
|
if (id.includes("search") || id.includes("computer-use")) return false;
|
||||||
|
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 {
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
model: params.model,
|
||||||
|
messages: params.messages,
|
||||||
|
enabledTools,
|
||||||
|
userLocation: params.userLocation,
|
||||||
|
temperature: params.temperature,
|
||||||
|
maxTokens: params.maxTokens,
|
||||||
|
logContext: params.logContext,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectChatProtocol(spec: ProviderBackendSpec, params: Pick<ProviderAdapterParams, "enabledTools">) {
|
||||||
|
const enabledTools = normalizeEnabledChatTools(params.enabledTools);
|
||||||
|
const useManagedTools = spec.managedTools === true && spec.toolProtocol && enabledTools.length > 0;
|
||||||
|
return {
|
||||||
|
protocol: useManagedTools ? spec.toolProtocol! : spec.plainProtocol,
|
||||||
|
enabledTools: useManagedTools ? enabledTools : [],
|
||||||
|
managedTools: Boolean(useManagedTools),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProviderChatAdapter(provider: Provider, spec: ProviderBackendSpec): ProviderChatAdapter {
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
complete(params) {
|
||||||
|
const selected = selectChatProtocol(spec, params);
|
||||||
|
return selected.protocol.complete(withClient(params, spec.createClient(), selected.enabledTools));
|
||||||
|
},
|
||||||
|
stream(params) {
|
||||||
|
const selected = selectChatProtocol(spec, params);
|
||||||
|
return selected.protocol.stream(withClient(params, spec.createClient(), selected.enabledTools));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendSpecs: Record<Provider, ProviderBackendSpec> = {
|
||||||
|
openai: {
|
||||||
|
createClient: openaiClient,
|
||||||
|
plainProtocol: chatCompletionsProtocol,
|
||||||
|
toolProtocol: responsesProtocol,
|
||||||
|
managedTools: true,
|
||||||
|
modelCatalog: {
|
||||||
|
async fetchModels(client) {
|
||||||
|
const page = await client.models.list();
|
||||||
|
return modelIdsFromListResponse(page).filter(isLikelyResponsesApiModel);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
anthropic: {
|
||||||
|
createClient: anthropicClient,
|
||||||
|
plainProtocol: messagesProtocol,
|
||||||
|
toolProtocol: messagesProtocol,
|
||||||
|
managedTools: true,
|
||||||
|
modelCatalog: {
|
||||||
|
async fetchModels(client) {
|
||||||
|
const page = await client.models.list({ limit: 200 });
|
||||||
|
return modelIdsFromListResponse(page);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
xai: {
|
||||||
|
createClient: xaiClient,
|
||||||
|
plainProtocol: chatCompletionsProtocol,
|
||||||
|
toolProtocol: chatCompletionsProtocol,
|
||||||
|
managedTools: true,
|
||||||
|
modelCatalog: {
|
||||||
|
async fetchModels(client) {
|
||||||
|
const page = await client.models.list();
|
||||||
|
return modelIdsFromListResponse(page);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
managedTools: false,
|
||||||
|
modelCatalog: {
|
||||||
|
enabled: isHermesAgentConfigured,
|
||||||
|
async fetchModels(client) {
|
||||||
|
const page = await client.models.list();
|
||||||
|
const models = modelIdsFromListResponse(page);
|
||||||
|
if (env.HERMES_AGENT_MODEL) models.push(env.HERMES_AGENT_MODEL);
|
||||||
|
return models;
|
||||||
|
},
|
||||||
|
fallbackModels() {
|
||||||
|
return env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerChatAdapters: Record<Provider, ProviderChatAdapter> = Object.fromEntries(
|
||||||
|
Object.entries(backendSpecs).map(([provider, spec]) => [provider, createProviderChatAdapter(provider as Provider, spec)])
|
||||||
|
) as Record<Provider, ProviderChatAdapter>;
|
||||||
|
|
||||||
|
export function getProviderChatAdapter(provider: Provider) {
|
||||||
|
return providerChatAdapters[provider];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeProviderChatBackend(provider: Provider, enabledTools?: string[]) {
|
||||||
|
const selected = selectChatProtocol(backendSpecs[provider], { enabledTools });
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
protocol: selected.protocol.id,
|
||||||
|
managedTools: selected.managedTools,
|
||||||
|
enabledTools: selected.enabledTools,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listModelCatalogProviders(): Provider[] {
|
||||||
|
return (Object.entries(backendSpecs) as [Provider, ProviderBackendSpec][])
|
||||||
|
.filter(([, spec]) => {
|
||||||
|
const catalog = spec.modelCatalog;
|
||||||
|
return catalog !== undefined && catalog.enabled?.() !== false;
|
||||||
|
})
|
||||||
|
.map(([provider]) => provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProviderCatalogModels(provider: Provider) {
|
||||||
|
const spec = backendSpecs[provider].modelCatalog;
|
||||||
|
if (!spec) return [];
|
||||||
|
const models = uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient()));
|
||||||
|
return spec.sortModels ? spec.sortModels(models) : models;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProviderCatalogFallbackModels(provider: Provider) {
|
||||||
|
return uniqSorted(backendSpecs[provider].modelCatalog?.fallbackModels?.() ?? []);
|
||||||
|
}
|
||||||
@@ -2,15 +2,30 @@ import type { Provider } from "./types.js";
|
|||||||
|
|
||||||
type PrismaProvider = Exclude<Provider, "hermes-agent"> | "hermes_agent";
|
type PrismaProvider = Exclude<Provider, "hermes-agent"> | "hermes_agent";
|
||||||
|
|
||||||
|
const apiToPrismaProvider = {
|
||||||
|
openai: "openai",
|
||||||
|
anthropic: "anthropic",
|
||||||
|
xai: "xai",
|
||||||
|
gemini: "gemini",
|
||||||
|
"hermes-agent": "hermes_agent",
|
||||||
|
} as const satisfies Record<Provider, PrismaProvider>;
|
||||||
|
|
||||||
|
const prismaToApiProvider = {
|
||||||
|
openai: "openai",
|
||||||
|
anthropic: "anthropic",
|
||||||
|
xai: "xai",
|
||||||
|
gemini: "gemini",
|
||||||
|
hermes_agent: "hermes-agent",
|
||||||
|
"hermes-agent": "hermes-agent",
|
||||||
|
} as const satisfies Record<PrismaProvider | "hermes-agent", Provider>;
|
||||||
|
|
||||||
export function toPrismaProvider(provider: Provider): PrismaProvider {
|
export function toPrismaProvider(provider: Provider): PrismaProvider {
|
||||||
return provider === "hermes-agent" ? "hermes_agent" : provider;
|
return apiToPrismaProvider[provider];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fromPrismaProvider(provider: unknown): Provider | null {
|
export function fromPrismaProvider(provider: unknown): Provider | null {
|
||||||
if (provider === null || provider === undefined) return null;
|
if (provider === null || provider === undefined) return null;
|
||||||
if (provider === "hermes_agent" || provider === "hermes-agent") return "hermes-agent";
|
return prismaToApiProvider[provider as keyof typeof prismaToApiProvider] ?? null;
|
||||||
if (provider === "openai" || provider === "anthropic" || provider === "xai") return provider;
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeProviderFields<T extends Record<string, any>>(value: T): T {
|
export function serializeProviderFields<T extends Record<string, any>>(value: T): T {
|
||||||
|
|||||||
@@ -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,15 +1,10 @@
|
|||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import { prisma } from "../db.js";
|
import { prisma } from "../db.js";
|
||||||
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
|
||||||
import {
|
import {
|
||||||
buildToolLogMessageData,
|
buildToolLogMessageData,
|
||||||
normalizeEnabledChatTools,
|
|
||||||
runPlainChatCompletionsStream,
|
|
||||||
runToolAwareChatCompletionsStream,
|
|
||||||
runToolAwareOpenAIChatStream,
|
|
||||||
type ToolExecutionEvent,
|
type ToolExecutionEvent,
|
||||||
} from "./chat-tools.js";
|
} from "./chat-tools.js";
|
||||||
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
import { getProviderChatAdapter } from "./provider-adapters.js";
|
||||||
import { toPrismaProvider } from "./provider-ids.js";
|
import { toPrismaProvider } from "./provider-ids.js";
|
||||||
import type { MultiplexRequest, Provider } from "./types.js";
|
import type { MultiplexRequest, Provider } from "./types.js";
|
||||||
|
|
||||||
@@ -75,44 +70,11 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
let raw: unknown = { streamed: true };
|
let raw: unknown = { streamed: true };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
|
const adapter = getProviderChatAdapter(req.provider);
|
||||||
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
|
const streamEvents = adapter.stream({
|
||||||
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
|
|
||||||
const streamEvents =
|
|
||||||
req.provider === "openai" && enabledTools.length > 0
|
|
||||||
? runToolAwareOpenAIChatStream({
|
|
||||||
client,
|
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
messages: req.messages,
|
||||||
enabledTools,
|
enabledTools: req.enabledTools,
|
||||||
userLocation: req.userLocation,
|
|
||||||
temperature: req.temperature,
|
|
||||||
maxTokens: req.maxTokens,
|
|
||||||
logContext: {
|
|
||||||
provider: req.provider,
|
|
||||||
model: req.model,
|
|
||||||
chatId: chatId ?? undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: req.provider === "hermes-agent" || enabledTools.length === 0
|
|
||||||
? runPlainChatCompletionsStream({
|
|
||||||
client,
|
|
||||||
model: req.model,
|
|
||||||
messages: req.messages,
|
|
||||||
userLocation: req.userLocation,
|
|
||||||
temperature: req.temperature,
|
|
||||||
maxTokens: req.maxTokens,
|
|
||||||
logContext: {
|
|
||||||
provider: req.provider,
|
|
||||||
model: req.model,
|
|
||||||
chatId: chatId ?? undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: runToolAwareChatCompletionsStream({
|
|
||||||
client,
|
|
||||||
model: req.model,
|
|
||||||
messages: req.messages,
|
|
||||||
enabledTools,
|
|
||||||
userLocation: req.userLocation,
|
userLocation: req.userLocation,
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
maxTokens: req.maxTokens,
|
maxTokens: req.maxTokens,
|
||||||
@@ -122,6 +84,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
chatId: chatId ?? undefined,
|
chatId: chatId ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
for await (const ev of streamEvents) {
|
for await (const ev of streamEvents) {
|
||||||
if (ev.type === "delta") {
|
if (ev.type === "delta") {
|
||||||
text += ev.text;
|
text += ev.text;
|
||||||
@@ -150,45 +113,6 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
usage = ev.result.usage;
|
usage = ev.result.usage;
|
||||||
text = ev.result.text;
|
text = ev.result.text;
|
||||||
}
|
}
|
||||||
} else if (req.provider === "anthropic") {
|
|
||||||
const client = anthropicClient();
|
|
||||||
|
|
||||||
const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
|
|
||||||
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
|
||||||
|
|
||||||
const stream = await client.messages.create({
|
|
||||||
model: req.model,
|
|
||||||
system,
|
|
||||||
max_tokens: req.maxTokens ?? 1024,
|
|
||||||
temperature: req.temperature,
|
|
||||||
messages: msgs as any,
|
|
||||||
stream: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
for await (const ev of stream as any as AsyncIterable<any>) {
|
|
||||||
// Anthropic streaming events include content_block_delta with text_delta
|
|
||||||
if (ev?.type === "content_block_delta" && ev?.delta?.type === "text_delta") {
|
|
||||||
const delta = ev.delta.text ?? "";
|
|
||||||
if (delta) {
|
|
||||||
text += delta;
|
|
||||||
yield { type: "delta", text: delta };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// capture usage if present on message_delta
|
|
||||||
if (ev?.type === "message_delta" && ev?.usage) {
|
|
||||||
usage = {
|
|
||||||
inputTokens: ev.usage.input_tokens,
|
|
||||||
outputTokens: ev.usage.output_tokens,
|
|
||||||
totalTokens:
|
|
||||||
(ev.usage.input_tokens ?? 0) + (ev.usage.output_tokens ?? 0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// some streams end with message_stop
|
|
||||||
}
|
|
||||||
raw = { streamed: true, provider: "anthropic" };
|
|
||||||
} else {
|
|
||||||
throw new Error(`unknown provider: ${req.provider}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const latencyMs = Math.round(performance.now() - t0);
|
const latencyMs = Math.round(performance.now() - t0);
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import {
|
import { type ToolAwareStreamingEvent } from "../src/llm/chat-tools.js";
|
||||||
runPlainChatCompletionsStream,
|
import { completeWithChatCompletionsApi, streamWithChatCompletionsApi } from "../src/llm/protocols/chat-completions-api.js";
|
||||||
runToolAwareChatCompletions,
|
import { completeWithMessagesApi, streamWithMessagesApi } from "../src/llm/protocols/messages-api.js";
|
||||||
runToolAwareChatCompletionsStream,
|
import { streamWithResponsesApi } from "../src/llm/protocols/responses-api.js";
|
||||||
runToolAwareOpenAIChatStream,
|
|
||||||
type ToolAwareStreamingEvent,
|
|
||||||
} from "../src/llm/chat-tools.js";
|
|
||||||
|
|
||||||
async function* streamFrom(events: any[]) {
|
async function* streamFrom(events: any[]) {
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
@@ -23,7 +20,7 @@ async function collectEvents(iterable: AsyncIterable<ToolAwareStreamingEvent>) {
|
|||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
test("OpenAI Responses stream emits text deltas as they arrive", async () => {
|
test("Responses API stream emits text deltas as they arrive", async () => {
|
||||||
const outputMessage = {
|
const outputMessage = {
|
||||||
id: "msg_1",
|
id: "msg_1",
|
||||||
type: "message",
|
type: "message",
|
||||||
@@ -53,7 +50,7 @@ test("OpenAI Responses stream emits text deltas as they arrive", async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const events = await collectEvents(
|
const events = await collectEvents(
|
||||||
runToolAwareOpenAIChatStream({
|
streamWithResponsesApi({
|
||||||
client: client as any,
|
client: client as any,
|
||||||
model: "gpt-test",
|
model: "gpt-test",
|
||||||
messages: [{ role: "user", content: "Say hello" }],
|
messages: [{ role: "user", content: "Say hello" }],
|
||||||
@@ -71,7 +68,7 @@ test("OpenAI Responses stream emits text deltas as they arrive", async () => {
|
|||||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hello");
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("OpenAI-compatible Chat Completions stream emits text deltas as they arrive", async () => {
|
test("Chat Completions API stream emits text deltas as they arrive", async () => {
|
||||||
const client = {
|
const client = {
|
||||||
chat: {
|
chat: {
|
||||||
completions: {
|
completions: {
|
||||||
@@ -90,7 +87,7 @@ test("OpenAI-compatible Chat Completions stream emits text deltas as they arrive
|
|||||||
};
|
};
|
||||||
|
|
||||||
const events = await collectEvents(
|
const events = await collectEvents(
|
||||||
runToolAwareChatCompletionsStream({
|
streamWithChatCompletionsApi({
|
||||||
client: client as any,
|
client: client as any,
|
||||||
model: "grok-test",
|
model: "grok-test",
|
||||||
messages: [{ role: "user", content: "Say hello" }],
|
messages: [{ role: "user", content: "Say hello" }],
|
||||||
@@ -125,10 +122,11 @@ test("plain Chat Completions stream does not send Sybil-managed tools", async ()
|
|||||||
};
|
};
|
||||||
|
|
||||||
const events = await collectEvents(
|
const events = await collectEvents(
|
||||||
runPlainChatCompletionsStream({
|
streamWithChatCompletionsApi({
|
||||||
client: client as any,
|
client: client as any,
|
||||||
model: "hermes-agent",
|
model: "hermes-agent",
|
||||||
messages: [{ role: "user", content: "Say hi" }],
|
messages: [{ role: "user", content: "Say hi" }],
|
||||||
|
enabledTools: [],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -142,6 +140,94 @@ test("plain Chat Completions stream does not send Sybil-managed tools", async ()
|
|||||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Messages API defaults max_tokens to the Anthropic model maximum", async () => {
|
||||||
|
let requestBody: any = null;
|
||||||
|
let retrievedModel: string | null = null;
|
||||||
|
const client = {
|
||||||
|
models: {
|
||||||
|
retrieve: async (model: string) => {
|
||||||
|
retrievedModel = model;
|
||||||
|
return { id: model, max_tokens: 128000 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
create: async (body: any) => {
|
||||||
|
requestBody = body;
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Done" }],
|
||||||
|
usage: { input_tokens: 1, output_tokens: 1 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await completeWithMessagesApi({
|
||||||
|
client: client as any,
|
||||||
|
model: "claude-max-default-test",
|
||||||
|
messages: [{ role: "user", content: "Say done" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(retrievedModel, "claude-max-default-test");
|
||||||
|
assert.equal(requestBody?.max_tokens, 128000);
|
||||||
|
assert.equal(result.text, "Done");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Messages API preserves explicit maxTokens", async () => {
|
||||||
|
let requestBody: any = null;
|
||||||
|
let didRetrieveModel = false;
|
||||||
|
const client = {
|
||||||
|
models: {
|
||||||
|
retrieve: async () => {
|
||||||
|
didRetrieveModel = true;
|
||||||
|
return { max_tokens: 128000 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
create: async (body: any) => {
|
||||||
|
requestBody = body;
|
||||||
|
return streamFrom([
|
||||||
|
{
|
||||||
|
type: "message_start",
|
||||||
|
message: {
|
||||||
|
usage: { input_tokens: 1, output_tokens: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "text", text: "" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 0,
|
||||||
|
delta: { type: "text_delta", text: "Done" },
|
||||||
|
},
|
||||||
|
{ type: "content_block_stop", index: 0 },
|
||||||
|
{
|
||||||
|
type: "message_delta",
|
||||||
|
delta: { stop_reason: "end_turn", stop_sequence: null },
|
||||||
|
usage: { output_tokens: 1 },
|
||||||
|
},
|
||||||
|
{ type: "message_stop" },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = await collectEvents(
|
||||||
|
streamWithMessagesApi({
|
||||||
|
client: client as any,
|
||||||
|
model: "claude-explicit-max-test",
|
||||||
|
messages: [{ role: "user", content: "Say done" }],
|
||||||
|
maxTokens: 4096,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(didRetrieveModel, false);
|
||||||
|
assert.equal(requestBody?.max_tokens, 4096);
|
||||||
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
|
||||||
|
});
|
||||||
|
|
||||||
test("fetch_url sends browser-like navigation headers", async () => {
|
test("fetch_url sends browser-like navigation headers", async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||||
@@ -189,7 +275,7 @@ test("fetch_url sends browser-like navigation headers", async () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await runToolAwareChatCompletions({
|
const result = await completeWithChatCompletionsApi({
|
||||||
client: client as any,
|
client: client as any,
|
||||||
model: "grok-test",
|
model: "grok-test",
|
||||||
messages: [{ role: "user", content: "Fetch CPI PDF" }],
|
messages: [{ role: "user", content: "Fetch CPI PDF" }],
|
||||||
@@ -215,7 +301,81 @@ test("fetch_url sends browser-like navigation headers", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("OpenAI-compatible Chat Completions stream emits initiated and terminal tool call updates", async () => {
|
test("Messages API executes tool_use blocks and sends tool_result follow-up", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
fetchCalls.push({ input, init });
|
||||||
|
return new Response("<!doctype html><title>Example</title><main>Tool result body</main>", {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/html; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBodies: any[] = [];
|
||||||
|
const client = {
|
||||||
|
messages: {
|
||||||
|
create: async (body: any) => {
|
||||||
|
requestBodies.push(body);
|
||||||
|
if (requestBodies.length === 1) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
id: "toolu_1",
|
||||||
|
name: "fetch_url",
|
||||||
|
input: { url: "https://example.com/article" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: { input_tokens: 3, output_tokens: 2 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Fetched" }],
|
||||||
|
usage: { input_tokens: 5, output_tokens: 1 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await completeWithMessagesApi({
|
||||||
|
client: client as any,
|
||||||
|
model: "claude-test",
|
||||||
|
messages: [{ role: "user", content: "Fetch the article" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.text, "Fetched");
|
||||||
|
assert.equal(fetchCalls.length, 1);
|
||||||
|
assert.equal(String(fetchCalls[0]?.input), "https://example.com/article");
|
||||||
|
assert.equal(requestBodies.length, 2);
|
||||||
|
assert.equal(requestBodies[0]?.model, "claude-test");
|
||||||
|
assert.equal(requestBodies[0]?.tool_choice?.type, "auto");
|
||||||
|
const fetchTool = requestBodies[0]?.tools?.find((tool: any) => tool.name === "fetch_url");
|
||||||
|
assert.equal(fetchTool?.input_schema?.type, "object");
|
||||||
|
assert.equal(fetchTool?.input_schema?.properties?.url?.type, "string");
|
||||||
|
|
||||||
|
const secondMessages = requestBodies[1]?.messages ?? [];
|
||||||
|
assert.equal(secondMessages.at(-2)?.role, "assistant");
|
||||||
|
assert.equal(secondMessages.at(-2)?.content?.[0]?.type, "tool_use");
|
||||||
|
assert.equal(secondMessages.at(-1)?.role, "user");
|
||||||
|
const toolResult = secondMessages.at(-1)?.content?.[0];
|
||||||
|
assert.equal(toolResult?.type, "tool_result");
|
||||||
|
assert.equal(toolResult?.tool_use_id, "toolu_1");
|
||||||
|
assert.equal(toolResult?.is_error, false);
|
||||||
|
assert.equal(JSON.parse(toolResult?.content ?? "{}").ok, true);
|
||||||
|
assert.equal(result.toolEvents[0]?.toolCallId, "toolu_1");
|
||||||
|
assert.equal(result.toolEvents[0]?.status, "completed");
|
||||||
|
assert.equal(result.usage?.inputTokens, 8);
|
||||||
|
assert.equal(result.usage?.outputTokens, 3);
|
||||||
|
assert.equal(result.usage?.totalTokens, 11);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Chat Completions API stream emits initiated and terminal tool call updates", async () => {
|
||||||
let requestCount = 0;
|
let requestCount = 0;
|
||||||
const client = {
|
const client = {
|
||||||
chat: {
|
chat: {
|
||||||
@@ -256,7 +416,7 @@ test("OpenAI-compatible Chat Completions stream emits initiated and terminal too
|
|||||||
};
|
};
|
||||||
|
|
||||||
const events = await collectEvents(
|
const events = await collectEvents(
|
||||||
runToolAwareChatCompletionsStream({
|
streamWithChatCompletionsApi({
|
||||||
client: client as any,
|
client: client as any,
|
||||||
model: "grok-test",
|
model: "grok-test",
|
||||||
messages: [{ role: "user", content: "Use a tool" }],
|
messages: [{ role: "user", content: "Use a tool" }],
|
||||||
@@ -280,3 +440,122 @@ test("OpenAI-compatible Chat Completions stream emits initiated and terminal too
|
|||||||
assert.equal(typeof toolEvents[1]?.durationMs, "number");
|
assert.equal(typeof toolEvents[1]?.durationMs, "number");
|
||||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Messages API stream emits initiated and terminal tool call updates", async () => {
|
||||||
|
let requestCount = 0;
|
||||||
|
const requestBodies: any[] = [];
|
||||||
|
const client = {
|
||||||
|
messages: {
|
||||||
|
create: async (body: any) => {
|
||||||
|
requestCount += 1;
|
||||||
|
requestBodies.push(body);
|
||||||
|
if (requestCount === 1) {
|
||||||
|
return streamFrom([
|
||||||
|
{
|
||||||
|
type: "message_start",
|
||||||
|
message: {
|
||||||
|
usage: { input_tokens: 3, output_tokens: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "text", text: "" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 0,
|
||||||
|
delta: { type: "text_delta", text: "I'll check that." },
|
||||||
|
},
|
||||||
|
{ type: "content_block_stop", index: 0 },
|
||||||
|
{
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 1,
|
||||||
|
content_block: {
|
||||||
|
type: "tool_use",
|
||||||
|
id: "toolu_1",
|
||||||
|
name: "unknown_tool",
|
||||||
|
input: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 1,
|
||||||
|
delta: { type: "input_json_delta", partial_json: "{\"query\":\"current weather\"}" },
|
||||||
|
},
|
||||||
|
{ type: "content_block_stop", index: 1 },
|
||||||
|
{
|
||||||
|
type: "message_delta",
|
||||||
|
delta: { stop_reason: "tool_use", stop_sequence: null },
|
||||||
|
usage: { output_tokens: 2 },
|
||||||
|
},
|
||||||
|
{ type: "message_stop" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamFrom([
|
||||||
|
{
|
||||||
|
type: "message_start",
|
||||||
|
message: {
|
||||||
|
usage: { input_tokens: 4, output_tokens: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "text", text: "" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 0,
|
||||||
|
delta: { type: "text_delta", text: "Done" },
|
||||||
|
},
|
||||||
|
{ type: "content_block_stop", index: 0 },
|
||||||
|
{
|
||||||
|
type: "message_delta",
|
||||||
|
delta: { stop_reason: "end_turn", stop_sequence: null },
|
||||||
|
usage: { output_tokens: 1 },
|
||||||
|
},
|
||||||
|
{ type: "message_stop" },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = await collectEvents(
|
||||||
|
streamWithMessagesApi({
|
||||||
|
client: client as any,
|
||||||
|
model: "claude-test",
|
||||||
|
messages: [{ role: "user", content: "Use a tool" }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
events.map((event) => event.type),
|
||||||
|
["tool_call", "tool_call", "delta", "done"]
|
||||||
|
);
|
||||||
|
assert.equal(requestBodies[0]?.stream, true);
|
||||||
|
assert.equal(requestBodies[0]?.tools?.some((tool: any) => tool.name === "fetch_url"), true);
|
||||||
|
|
||||||
|
const secondMessages = requestBodies[1]?.messages ?? [];
|
||||||
|
assert.equal(secondMessages.at(-2)?.role, "assistant");
|
||||||
|
assert.equal(secondMessages.at(-2)?.content?.[0]?.type, "text");
|
||||||
|
assert.equal(secondMessages.at(-2)?.content?.[0]?.text, "I'll check that.");
|
||||||
|
assert.equal(secondMessages.at(-2)?.content?.[1]?.type, "tool_use");
|
||||||
|
assert.deepEqual(secondMessages.at(-2)?.content?.[1]?.input, { query: "current weather" });
|
||||||
|
const toolResult = secondMessages.at(-1)?.content?.[0];
|
||||||
|
assert.equal(toolResult?.type, "tool_result");
|
||||||
|
assert.equal(toolResult?.tool_use_id, "toolu_1");
|
||||||
|
assert.equal(toolResult?.is_error, true);
|
||||||
|
assert.match(JSON.parse(toolResult?.content ?? "{}").error ?? "", /Unknown tool: unknown_tool/);
|
||||||
|
|
||||||
|
const toolEvents = events.flatMap((event) => (event.type === "tool_call" ? [event.event] : []));
|
||||||
|
assert.equal(toolEvents[0]?.toolCallId, "toolu_1");
|
||||||
|
assert.equal(toolEvents[0]?.status, "initiated");
|
||||||
|
assert.equal(toolEvents[1]?.toolCallId, "toolu_1");
|
||||||
|
assert.equal(toolEvents[1]?.status, "failed");
|
||||||
|
assert.match(toolEvents[1]?.error ?? "", /Unknown tool: unknown_tool/);
|
||||||
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
|
||||||
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.usage?.inputTokens : null, 7);
|
||||||
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.usage?.outputTokens : null, 3);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { buildSystemPromptAugmentation, getAnthropicSystemPrompt } from "../src/llm/message-content.js";
|
import { buildSystemPromptAugmentation, buildTopLevelSystemPrompt } from "../src/llm/message-content.js";
|
||||||
|
|
||||||
test("system prompt augmentation includes date and default location", () => {
|
test("system prompt augmentation includes date and default location", () => {
|
||||||
const prompt = buildSystemPromptAugmentation(undefined, new Date("2026-05-24T15:30:00Z"));
|
const prompt = buildSystemPromptAugmentation(undefined, new Date("2026-05-24T15:30:00Z"));
|
||||||
@@ -14,8 +14,8 @@ test("system prompt augmentation uses provided user location", () => {
|
|||||||
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: New York, NY.");
|
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: New York, NY.");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Anthropic system prompt includes runtime context with existing system messages", () => {
|
test("top-level system prompt includes runtime context with existing system messages", () => {
|
||||||
const prompt = getAnthropicSystemPrompt(
|
const prompt = buildTopLevelSystemPrompt(
|
||||||
[{ role: "system", content: "Use concise answers." }],
|
[{ role: "system", content: "Use concise answers." }],
|
||||||
"Los Angeles, CA"
|
"Los Angeles, CA"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { describeProviderChatBackend } from "../src/llm/provider-adapters.js";
|
||||||
|
|
||||||
|
test("provider backend registry selects chat protocol and managed-tool mode", () => {
|
||||||
|
assert.deepEqual(describeProviderChatBackend("openai", []), {
|
||||||
|
provider: "openai",
|
||||||
|
protocol: "chat-completions",
|
||||||
|
managedTools: false,
|
||||||
|
enabledTools: [],
|
||||||
|
});
|
||||||
|
assert.deepEqual(describeProviderChatBackend("openai", ["web_search"]), {
|
||||||
|
provider: "openai",
|
||||||
|
protocol: "responses",
|
||||||
|
managedTools: true,
|
||||||
|
enabledTools: ["web_search"],
|
||||||
|
});
|
||||||
|
assert.deepEqual(describeProviderChatBackend("anthropic", ["web_search"]), {
|
||||||
|
provider: "anthropic",
|
||||||
|
protocol: "messages",
|
||||||
|
managedTools: true,
|
||||||
|
enabledTools: ["web_search"],
|
||||||
|
});
|
||||||
|
assert.deepEqual(describeProviderChatBackend("xai", ["web_search"]), {
|
||||||
|
provider: "xai",
|
||||||
|
protocol: "chat-completions",
|
||||||
|
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",
|
||||||
|
managedTools: false,
|
||||||
|
enabledTools: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,5 +1,5 @@
|
|||||||
import { useMemo, useRef, useState } from "preact/hooks";
|
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
import type { JSX } from "preact";
|
import type { ComponentChildren, JSX } from "preact";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||||
import { getMessageAttachments, type Message } from "@/lib/api";
|
import { getMessageAttachments, type Message } from "@/lib/api";
|
||||||
@@ -142,6 +142,14 @@ function buildMessageRenderItems(messages: Message[]) {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getToolCallMessageIDs(messages: Message[]) {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.role === "tool" && asToolLogMetadata(message.metadata)) ids.add(message.id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
function getToolStackHeight(messageCount: number, expanded: boolean) {
|
function getToolStackHeight(messageCount: number, expanded: boolean) {
|
||||||
const visibleCount = Math.min(messageCount, COLLAPSED_TOOL_STACK_LIMIT);
|
const visibleCount = Math.min(messageCount, COLLAPSED_TOOL_STACK_LIMIT);
|
||||||
return expanded
|
return expanded
|
||||||
@@ -246,10 +254,10 @@ function ToolCallCard({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
|
"inline-flex min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
|
||||||
isFailed
|
isFailed
|
||||||
? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]"
|
? "border-rose-400/44 bg-[linear-gradient(90deg,hsl(350_64%_20%),hsl(342_58%_9%))]"
|
||||||
: isInitiated
|
: isInitiated
|
||||||
? "border-amber-300/34 bg-[linear-gradient(90deg,hsl(43_74%_30%_/_0.34),hsl(260_48%_13%_/_0.74))]"
|
? "border-amber-300/44 bg-[linear-gradient(90deg,hsl(43_72%_20%),hsl(260_48%_13%))]"
|
||||||
: "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]",
|
: "border-cyan-400/44 bg-[linear-gradient(90deg,hsl(184_82%_14%),hsl(208_66%_10%))]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={style}
|
style={style}
|
||||||
@@ -280,15 +288,40 @@ function ToolCallCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ToolCallStackCardSurface({
|
||||||
|
messageID,
|
||||||
|
animateEntry,
|
||||||
|
isHidden,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
messageID: string;
|
||||||
|
animateEntry: boolean;
|
||||||
|
isHidden: boolean;
|
||||||
|
children: ComponentChildren;
|
||||||
|
}) {
|
||||||
|
const [shouldAnimateEntry] = useState(() => animateEntry);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("tool-call-stack-card-surface", shouldAnimateEntry && !isHidden && "tool-call-stack-card-enter")}
|
||||||
|
data-tool-stack-card-id={messageID}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ToolCallStack({
|
function ToolCallStack({
|
||||||
groupKey,
|
groupKey,
|
||||||
messages,
|
messages,
|
||||||
expanded,
|
expanded,
|
||||||
|
entryMessageIDs,
|
||||||
onToggle,
|
onToggle,
|
||||||
}: {
|
}: {
|
||||||
groupKey: string;
|
groupKey: string;
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
|
entryMessageIDs: Set<string>;
|
||||||
onToggle: (groupKey: string) => void;
|
onToggle: (groupKey: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const hiddenCount = Math.max(0, messages.length - COLLAPSED_TOOL_STACK_LIMIT);
|
const hiddenCount = Math.max(0, messages.length - COLLAPSED_TOOL_STACK_LIMIT);
|
||||||
@@ -324,6 +357,7 @@ function ToolCallStack({
|
|||||||
{messages.map((message, index) => {
|
{messages.map((message, index) => {
|
||||||
const depth = messages.length - index - 1;
|
const depth = messages.length - index - 1;
|
||||||
const isHidden = !expanded && depth >= COLLAPSED_TOOL_STACK_LIMIT;
|
const isHidden = !expanded && depth >= COLLAPSED_TOOL_STACK_LIMIT;
|
||||||
|
const shouldAnimateEntry = entryMessageIDs.has(message.id) && !isHidden;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
@@ -335,12 +369,9 @@ function ToolCallStack({
|
|||||||
style={getToolStackStyle(index, messages.length, expanded, motionDirection)}
|
style={getToolStackStyle(index, messages.length, expanded, motionDirection)}
|
||||||
aria-hidden={isHidden ? "true" : undefined}
|
aria-hidden={isHidden ? "true" : undefined}
|
||||||
>
|
>
|
||||||
<div
|
<ToolCallStackCardSurface messageID={message.id} animateEntry={shouldAnimateEntry} isHidden={isHidden}>
|
||||||
className={cn("tool-call-stack-card-surface", !isHidden && "tool-call-stack-card-enter")}
|
|
||||||
data-tool-stack-card-id={message.id}
|
|
||||||
>
|
|
||||||
<ToolCallCard message={message} className="tool-call-stack-card-glass w-full max-w-full" />
|
<ToolCallCard message={message} className="tool-call-stack-card-glass w-full max-w-full" />
|
||||||
</div>
|
</ToolCallStackCardSurface>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -367,8 +398,26 @@ function ToolCallStack({
|
|||||||
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
||||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
||||||
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
||||||
|
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
|
||||||
|
const seenToolCallMessageIDsRef = useRef<Set<string> | null>(null);
|
||||||
|
const entryToolCallMessageIDs = useMemo(() => {
|
||||||
|
const seenIDs = seenToolCallMessageIDsRef.current;
|
||||||
|
if (!seenIDs) return new Set<string>();
|
||||||
|
const entryIDs = new Set<string>();
|
||||||
|
for (const id of toolCallMessageIDs) {
|
||||||
|
if (!seenIDs.has(id)) entryIDs.add(id);
|
||||||
|
}
|
||||||
|
return entryIDs;
|
||||||
|
}, [toolCallMessageIDs]);
|
||||||
const [expandedToolGroups, setExpandedToolGroups] = useState<Set<string>>(() => new Set());
|
const [expandedToolGroups, setExpandedToolGroups] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toolCallMessageIDs.size) return;
|
||||||
|
const seenIDs = seenToolCallMessageIDsRef.current ?? new Set<string>();
|
||||||
|
for (const id of toolCallMessageIDs) seenIDs.add(id);
|
||||||
|
seenToolCallMessageIDsRef.current = seenIDs;
|
||||||
|
}, [toolCallMessageIDs]);
|
||||||
|
|
||||||
const toggleToolGroup = (groupKey: string) => {
|
const toggleToolGroup = (groupKey: string) => {
|
||||||
setExpandedToolGroups((current) => {
|
setExpandedToolGroups((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
@@ -390,6 +439,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
|||||||
groupKey={item.key}
|
groupKey={item.key}
|
||||||
messages={item.messages}
|
messages={item.messages}
|
||||||
expanded={expandedToolGroups.has(item.key)}
|
expanded={expandedToolGroups.has(item.key)}
|
||||||
|
entryMessageIDs={entryToolCallMessageIDs}
|
||||||
onToggle={toggleToolGroup}
|
onToggle={toggleToolGroup}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
+31
-2
@@ -177,7 +177,7 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-stack-card-glass {
|
.tool-call-stack-card-glass {
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-stack-card-enter {
|
.tool-call-stack-card-enter {
|
||||||
@@ -286,6 +286,14 @@ textarea {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-content > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.md-table-scroll {
|
.md-table-scroll {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: 0.35rem 0 1rem;
|
margin: 0.35rem 0 1rem;
|
||||||
@@ -384,7 +392,8 @@ textarea {
|
|||||||
|
|
||||||
.md-content ul,
|
.md-content ul,
|
||||||
.md-content ol {
|
.md-content ol {
|
||||||
margin-top: 0.65rem;
|
margin-top: 0.85rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -396,6 +405,26 @@ textarea {
|
|||||||
padding-left: 1.35rem;
|
padding-left: 1.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-content ul > li {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content ul > li::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0.76em;
|
||||||
|
width: 0.36rem;
|
||||||
|
height: 0.36rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: hsl(188 86% 62%);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px hsl(188 86% 62% / 0.12),
|
||||||
|
0 0 10px hsl(188 86% 62% / 0.42);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
.md-content li + li {
|
.md-content li + li {
|
||||||
margin-top: 0.3rem;
|
margin-top: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|||||||
+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