Compare commits

...
Author SHA1 Message Date
buzzert 20a310a4b1 ci: use Ruby 3.1 for TestFlight upload
TestFlight / testflight (push) Failing after 46s
2026-07-11 12:01:27 -07:00
buzzert b6859706db ios: simplify CI signing keychain setup
TestFlight / testflight (push) Failing after 25s
2026-07-11 11:55:04 -07:00
buzzert ea148839d3 Revert "ios: pass match keychain to codesign"
This reverts commit a0e410155d.
2026-07-11 11:32:00 -07:00
buzzert a0e410155d ios: pass match keychain to codesign
TestFlight / testflight (push) Failing after 55s
2026-07-11 11:29:10 -07:00
buzzert b5a5d21767 docs: document iOS release workflow 2026-07-11 11:17:05 -07:00
buzzert 0ad96f4f99 ios: center transcript content on iPad
TestFlight / testflight (push) Failing after 52s
2026-07-11 11:15:01 -07:00
buzzert b0c2b74c16 remove temporary keychain (unneeded)
TestFlight / testflight (push) Successful in 1m46s
2026-07-06 09:25:24 -07:00
buzzert 14d2d8cb28 fix spacing between lists and paragraphs 2026-07-05 19:54:34 -07:00
buzzert 03836bbc4c Raise Anthropic default max tokens 2026-07-05 11:53:17 -07:00
buzzert 76ce62025a bullets in ul 2026-07-04 20:22:13 -07:00
buzzert b15473d24e ios: stamp release version into XcodeGen spec
TestFlight / testflight (push) Successful in 1m43s
2026-06-26 01:29:54 -07:00
buzzert a512a65844 ios: accept release tag namespace in Fastfile
TestFlight / testflight (push) Successful in 1m55s
2026-06-26 01:18:02 -07:00
buzzert 207b44f67f ios: ci: actually release/ios/v*.
TestFlight / testflight (push) Failing after 19s
2026-06-26 01:15:08 -07:00
buzzert c5ccd212c9 ios: ci: testflight only on release/v*
TestFlight / testflight (push) Successful in 1m55s
2026-06-26 01:08:47 -07:00
buzzertandClaude Opus 4.8 ee990dde5d ios: simplify Fastfile signing now that the runner has a real session
With SessionCreate on the runner's launchd job, standard fastlane keychain
handling works, so drop the debugging-era workarounds: the manual
default-keychain / list-keychains search-list juggling, the login-keychain
restoration in cleanup, the verify_ci_signing re-unlock/partition/find-identity
step (match already imports the cert and sets the key partition list), and the
CODE_SIGN_KEYCHAIN / OTHER_CODE_SIGN_FLAGS xcargs. CI signing is now a single
create_keychain + match. No behavior change; validated end-to-end on TestFlight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 00:52:36 -07:00
12 changed files with 261 additions and 160 deletions
+7 -19
View File
@@ -4,11 +4,11 @@ on:
workflow_dispatch: workflow_dispatch:
push: push:
tags: tags:
- "v*" - "release/ios/v*"
jobs: jobs:
testflight: testflight:
runs-on: xcode runs-on: macos-arm64
defaults: defaults:
run: run:
shell: bash shell: bash
@@ -33,29 +33,17 @@ jobs:
brew install xcodegen brew install xcodegen
fi fi
- name: Prepare Runner Keychain - name: Runner diagnostics
env:
HOME: /var/lib/act_runner
run: | run: |
set -euo pipefail set -euo pipefail
mkdir -p "${HOME}/Library/Keychains" whoami
printf 'HOME=%s\n' "$HOME"
login_keychain="${HOME}/Library/Keychains/login.keychain" security default-keychain -d user || true
if [ ! -f "${login_keychain}-db" ]; then security list-keychains -d user || true
security create-keychain -p "" "${login_keychain}"
fi
security unlock-keychain -p "" "${login_keychain}" 2>/dev/null || \
security unlock-keychain -p "sybil-ci-keychain-password" "${login_keychain}" 2>/dev/null || true
security default-keychain -d user -s "${login_keychain}"
security list-keychains -d user -s "${login_keychain}-db"
security delete-keychain "${HOME}/Library/Keychains/sybil_ci_keychain" >/dev/null 2>&1 || true
rm -f "${HOME}/Library/Keychains/sybil_ci_keychain" "${HOME}/Library/Keychains/sybil_ci_keychain-db"
- name: Upload to TestFlight - name: Upload to TestFlight
working-directory: ios working-directory: ios
env: env:
HOME: /var/lib/act_runner
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }} APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }}
+1
View File
@@ -285,6 +285,7 @@ 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`.
+1
View File
@@ -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:
+6
View File
@@ -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:
+1 -1
View File
@@ -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
@@ -58,7 +58,8 @@ 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)
} }
@@ -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)
@@ -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)
+56 -115
View File
@@ -1,4 +1,3 @@
require "fileutils"
require "shellwords" require "shellwords"
default_platform(:ios) default_platform(:ios)
@@ -7,56 +6,56 @@ APP_IDENTIFIER = "net.buzzert.sybil2"
SCHEME = "Sybil" SCHEME = "Sybil"
TEAM_ID = "DQQH5H6GBD" TEAM_ID = "DQQH5H6GBD"
PROFILE_NAME = "Sybil AppStore CI" PROFILE_NAME = "Sybil AppStore CI"
SIGNING_IDENTITY = "Apple Distribution: James Magahern (DQQH5H6GBD)"
CI_KEYCHAIN_NAME = "sybil_ci_keychain"
CI_KEYCHAIN_PASSWORD = "sybil-ci-keychain-password"
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")
CI_KEYCHAIN_PATH = File.join(File.expand_path("~/Library/Keychains"), CI_KEYCHAIN_NAME) APP_PROJECT_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
CI_KEYCHAIN_DB_PATH = "#{CI_KEYCHAIN_PATH}-db"
LOGIN_KEYCHAIN_PATH = File.expand_path("~/Library/Keychains/login.keychain")
LOGIN_KEYCHAIN_DB_PATH = "#{LOGIN_KEYCHAIN_PATH}-db"
def present?(value) def present?(value)
!value.to_s.strip.empty? !value.to_s.strip.empty?
end end
def release_version
tag = ENV["SYBIL_VERSION_TAG"].to_s
tag = ENV["GITHUB_REF_NAME"].to_s if !present?(tag)
tag = ENV["GITHUB_REF"].to_s.sub(%r{\Arefs/tags/}, "") if !present?(tag)
tag = sh("git describe --tags --abbrev=0").strip if !present?(tag)
version = tag.sub(%r{\Arelease/}, "").sub(/\Av/, "")
unless version.match?(/\A\d+\.\d+\.\d+\z/)
UI.user_error!("Release tag must look like v1.2.3; got #{tag.inspect}")
end
version
end
def ci? def ci?
present?(ENV["CI"]) present?(ENV["CI"])
end end
def release_version
tag = ENV["SYBIL_VERSION_TAG"]
tag = ENV["GITHUB_REF_NAME"] if !present?(tag)
tag = ENV["GITHUB_REF"].to_s.sub(%r{\Arefs/tags/}, "") if !present?(tag)
tag = sh("git describe --tags --abbrev=0").strip if !present?(tag)
match = tag.to_s.match(%r{\Arelease/ios/v(\d+\.\d+\.\d+)\z})
unless match
UI.user_error!("Release tag must look like release/ios/v1.2.3; got #{tag.inspect}")
end
match[1]
end
# App Store Connect requires CFBundleVersion to be unique and strictly # App Store Connect requires CFBundleVersion to be unique and strictly
# increasing app-wide (not just per marketing version), so we derive it from # 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 # the monotonic CI run number rather than querying TestFlight (that query can
# lag behind builds still processing and hand back a colliding value). # lag behind builds still processing and hand back a colliding value).
def build_number def build_number
value = ENV["SYBIL_BUILD_NUMBER"].to_s value = present?(ENV["SYBIL_BUILD_NUMBER"]) ? ENV["SYBIL_BUILD_NUMBER"] : ENV["GITHUB_RUN_NUMBER"]
value = ENV["GITHUB_RUN_NUMBER"].to_s if !present?(value)
unless value.match?(/\A\d+\z/) 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}") UI.user_error!("Build number must come from SYBIL_BUILD_NUMBER/GITHUB_RUN_NUMBER; got #{value.inspect}")
end end
value.to_i value.to_i
end end
def ci_keychain_path def stamp_marketing_version(version)
File.file?(CI_KEYCHAIN_DB_PATH) ? CI_KEYCHAIN_DB_PATH : CI_KEYCHAIN_PATH 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
@@ -69,54 +68,24 @@ platform :ios do
) )
end end
private_lane :setup_ci_signing do # CI uses a throwaway keychain that is deleted after the lane exits.
next unless ci? private_lane :prepare_ci_keychain do
next nil unless ci?
FileUtils.mkdir_p(File.dirname(CI_KEYCHAIN_PATH)) run_token = "#{Time.now.to_i}_#{Process.pid}"
sh("security delete-keychain #{CI_KEYCHAIN_PATH.shellescape} || true", log: false) keychain_name = "fastlane_ci_#{run_token}"
FileUtils.rm_f(CI_KEYCHAIN_PATH)
FileUtils.rm_f(CI_KEYCHAIN_DB_PATH)
create_keychain( setup_ci(
path: CI_KEYCHAIN_PATH, force: true,
password: CI_KEYCHAIN_PASSWORD, keychain_name: keychain_name,
default_keychain: false, timeout: 7_200
unlock: true,
timeout: 3600,
lock_when_sleeps: true,
add_to_search_list: false
) )
sh("security default-keychain -d user -s #{CI_KEYCHAIN_PATH.shellescape}", log: false) keychain_name
sh("security list-keychains -d user -s #{ci_keychain_path.shellescape}", log: false)
sh("security list-keychains -d dynamic -s #{ci_keychain_path.shellescape} || true", log: false)
sh("security list-keychains -d common -s #{ci_keychain_path.shellescape} || true", log: false)
ENV["MATCH_KEYCHAIN_NAME"] = CI_KEYCHAIN_PATH
ENV["MATCH_KEYCHAIN_PASSWORD"] = CI_KEYCHAIN_PASSWORD
ENV["MATCH_READONLY"] = "true"
end
private_lane :cleanup_ci_signing do
next unless ci?
if File.file?(LOGIN_KEYCHAIN_DB_PATH) || File.file?(LOGIN_KEYCHAIN_PATH)
sh("security default-keychain -d user -s #{LOGIN_KEYCHAIN_PATH.shellescape} || true", log: false)
sh("security list-keychains -d user -s #{LOGIN_KEYCHAIN_DB_PATH.shellescape} || true", log: false)
end
sh("security delete-keychain #{ci_keychain_path.shellescape} || true", log: false)
FileUtils.rm_f(CI_KEYCHAIN_PATH)
FileUtils.rm_f(CI_KEYCHAIN_DB_PATH)
rescue => error
UI.message("Unable to delete temporary CI keychain: #{error.message}")
ensure
ENV.delete("MATCH_KEYCHAIN_NAME")
ENV.delete("MATCH_KEYCHAIN_PASSWORD")
ENV.delete("MATCH_READONLY")
end end
private_lane :sync_signing do |options| private_lane :sync_signing do |options|
match_options = { match(
type: "appstore", type: "appstore",
readonly: options.fetch(:readonly), readonly: options.fetch(:readonly),
app_identifier: APP_IDENTIFIER, app_identifier: APP_IDENTIFIER,
@@ -127,28 +96,7 @@ platform :ios do
git_full_name: "Sybil Release Bot", git_full_name: "Sybil Release Bot",
git_user_email: "james.magahern@me.com", git_user_email: "james.magahern@me.com",
api_key: options.fetch(:api_key) api_key: options.fetch(:api_key)
} )
match_options[:keychain_name] = ENV["MATCH_KEYCHAIN_NAME"] if present?(ENV["MATCH_KEYCHAIN_NAME"])
match_options[:keychain_password] = ENV["MATCH_KEYCHAIN_PASSWORD"] if ENV.key?("MATCH_KEYCHAIN_PASSWORD")
match(match_options)
end
private_lane :verify_ci_signing do
next unless ci?
if File.file?(ci_keychain_path)
password = ENV.fetch("MATCH_KEYCHAIN_PASSWORD", "")
sh("security unlock-keychain -p #{password.shellescape} #{ci_keychain_path.shellescape}", log: false)
sh("security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k #{password.shellescape} #{ci_keychain_path.shellescape}", log: false)
end
identities = sh("security find-identity -v -p codesigning #{ci_keychain_path.shellescape}", log: false)
UI.message(identities)
unless identities.include?(SIGNING_IDENTITY)
UI.user_error!("The CI keychain search list does not contain #{SIGNING_IDENTITY}")
end
end end
desc "Create or update match signing assets" desc "Create or update match signing assets"
@@ -158,43 +106,35 @@ platform :ios do
desc "Build and upload to TestFlight" desc "Build and upload to TestFlight"
lane :beta do lane :beta do
setup_ci_signing ci_keychain_name = nil
begin
ci_keychain_name = prepare_ci_keychain
api_key = app_store_api_key api_key = app_store_api_key
sh("xcodegen --spec #{PROJECT_SPEC.shellescape}") version = release_version
stamp_marketing_version(version)
sh("xcodegen", "--spec", PROJECT_SPEC)
increment_version_number( increment_version_number(version_number: version, xcodeproj: PROJECT_FILE)
version_number: release_version, increment_build_number(build_number: build_number, xcodeproj: PROJECT_FILE)
xcodeproj: PROJECT_FILE
)
increment_build_number(
build_number: build_number,
xcodeproj: PROJECT_FILE
)
sync_signing(api_key: api_key, readonly: true) sync_signing(api_key: api_key, readonly: true)
verify_ci_signing
xcargs = [ sh("security find-identity -v -p codesigning") if ci_keychain_name
"DEVELOPMENT_TEAM=#{TEAM_ID.shellescape}",
"CODE_SIGN_STYLE=Manual",
"CODE_SIGN_IDENTITY=Apple\\ Distribution",
"PROVISIONING_PROFILE_SPECIFIER=#{PROFILE_NAME.shellescape}"
]
if ci?
xcargs << "CODE_SIGN_KEYCHAIN=#{ci_keychain_path.shellescape}"
xcargs << "OTHER_CODE_SIGN_FLAGS=#{("--keychain #{ci_keychain_path}").shellescape}"
end
build_app( build_app(
project: PROJECT_FILE, project: PROJECT_FILE,
scheme: SCHEME, scheme: SCHEME,
export_method: "app-store", export_method: "app-store",
codesigning_identity: "Apple Distribution", codesigning_identity: "Apple Distribution",
xcargs: xcargs.join(" "), xcargs: [
"DEVELOPMENT_TEAM=#{TEAM_ID.shellescape}",
"CODE_SIGN_STYLE=Manual",
"CODE_SIGN_IDENTITY=Apple\\ Distribution",
"PROVISIONING_PROFILE_SPECIFIER=#{PROFILE_NAME.shellescape}"
].join(" "),
export_options: { export_options: {
signingStyle: "manual", signingStyle: "manual",
teamID: TEAM_ID, teamID: TEAM_ID,
@@ -209,6 +149,7 @@ platform :ios do
skip_waiting_for_build_processing: true skip_waiting_for_build_processing: true
) )
ensure ensure
cleanup_ci_signing delete_keychain(name: ci_keychain_name) if ci_keychain_name
end
end end
end end
+45 -4
View File
@@ -28,6 +28,45 @@ import type { ChatMessage } from "../types.js";
const INTERNAL_CORRECTION = 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."; "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[]) { function toTools(tools: any[]) {
return tools return tools
.map((tool) => { .map((tool) => {
@@ -160,11 +199,12 @@ function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
export async function completeWithMessagesApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> { export async function completeWithMessagesApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const enabledTools = getEnabledChatTools(params); const enabledTools = getEnabledChatTools(params);
const maxTokens = await getMessagesMaxTokens(params);
if (!enabledTools.length) { if (!enabledTools.length) {
const response = await params.client.messages.create({ const response = await params.client.messages.create({
model: params.model, model: params.model,
system: buildTopLevelSystemPrompt(params.messages, params.userLocation), system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
max_tokens: params.maxTokens ?? 1024, max_tokens: maxTokens,
temperature: params.temperature, temperature: params.temperature,
messages: buildBaseMessages(params), messages: buildBaseMessages(params),
} as any); } as any);
@@ -192,7 +232,7 @@ export async function completeWithMessagesApi(params: ToolAwareCompletionParams)
const response = await params.client.messages.create({ const response = await params.client.messages.create({
model: params.model, model: params.model,
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)), system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
max_tokens: params.maxTokens ?? 1024, max_tokens: maxTokens,
temperature: params.temperature, temperature: params.temperature,
messages: conversation, messages: conversation,
tools: toTools(enabledTools), tools: toTools(enabledTools),
@@ -248,6 +288,7 @@ export async function completeWithMessagesApi(params: ToolAwareCompletionParams)
export async function* streamWithMessagesApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> { export async function* streamWithMessagesApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
const enabledTools = getEnabledChatTools(params); const enabledTools = getEnabledChatTools(params);
const maxTokens = await getMessagesMaxTokens(params);
if (!enabledTools.length) { if (!enabledTools.length) {
const rawResponses: unknown[] = []; const rawResponses: unknown[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -259,7 +300,7 @@ export async function* streamWithMessagesApi(params: ToolAwareCompletionParams):
const stream = await params.client.messages.create({ const stream = await params.client.messages.create({
model: params.model, model: params.model,
system: buildTopLevelSystemPrompt(params.messages, params.userLocation), system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
max_tokens: params.maxTokens ?? 1024, max_tokens: maxTokens,
temperature: params.temperature, temperature: params.temperature,
messages: buildBaseMessages(params), messages: buildBaseMessages(params),
stream: true, stream: true,
@@ -315,7 +356,7 @@ export async function* streamWithMessagesApi(params: ToolAwareCompletionParams):
const stream = await params.client.messages.create({ const stream = await params.client.messages.create({
model: params.model, model: params.model,
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)), system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
max_tokens: params.maxTokens ?? 1024, max_tokens: maxTokens,
temperature: params.temperature, temperature: params.temperature,
messages: conversation, messages: conversation,
tools: toTools(enabledTools), tools: toTools(enabledTools),
+88
View File
@@ -140,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 }> = [];
+30 -1
View File
@@ -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;
} }