diff --git a/.github/scripts/package_release_binary.sh b/.github/scripts/package_release_binary.sh new file mode 100755 index 0000000..c92d5b6 --- /dev/null +++ b/.github/scripts/package_release_binary.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +RELEASE_TAG="${RELEASE_TAG:-${1:-}}" +REPO_ROOT="${REPO_ROOT:-$(pwd)}" +OUTPUT_DIR="${OUTPUT_DIR:-$REPO_ROOT/dist}" +ASSET_NAME="${ASSET_NAME:-afm-api-macos-arm64.tar.gz}" +ALLOW_SOURCE_FALLBACK="${AFM_ALLOW_SOURCE_FALLBACK:-1}" + +if [[ -z "$RELEASE_TAG" ]]; then + echo "ERROR: RELEASE_TAG is required (e.g. v1.2.3)" + exit 1 +fi +if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+)*$ ]]; then + echo "ERROR: RELEASE_TAG must match v..[-suffix]" + exit 1 +fi + +VERSION="${RELEASE_TAG#v}" + +mkdir -p "$OUTPUT_DIR" +rm -rf "$OUTPUT_DIR/stage" +mkdir -p "$OUTPUT_DIR/stage" + +ASSET_MODE="binary" +if swift build --package-path "$REPO_ROOT" -c release --product afm-api-server; then + cp "$REPO_ROOT/bin/afm-api" "$OUTPUT_DIR/stage/afm-api" + cp "$REPO_ROOT/.build/release/afm-api-server" "$OUTPUT_DIR/stage/afm-api-server" + chmod +x "$OUTPUT_DIR/stage/afm-api" "$OUTPUT_DIR/stage/afm-api-server" + perl -pi -e "s/__AFM_API_VERSION__/${VERSION}/g" "$OUTPUT_DIR/stage/afm-api" +else + if [[ "$ALLOW_SOURCE_FALLBACK" != "1" ]]; then + echo "ERROR: release binary build failed and source fallback is disabled." + exit 1 + fi + ASSET_MODE="source-fallback" + mkdir -p "$OUTPUT_DIR/stage/bin" + cp "$REPO_ROOT/bin/afm-api" "$OUTPUT_DIR/stage/bin/afm-api" + cp "$REPO_ROOT/Package.swift" "$OUTPUT_DIR/stage/Package.swift" + cp -R "$REPO_ROOT/Sources" "$OUTPUT_DIR/stage/Sources" + chmod +x "$OUTPUT_DIR/stage/bin/afm-api" + perl -pi -e "s/__AFM_API_VERSION__/${VERSION}/g" "$OUTPUT_DIR/stage/bin/afm-api" +fi + +if [[ "$ASSET_MODE" == "binary" ]]; then + tar -czf "$OUTPUT_DIR/$ASSET_NAME" -C "$OUTPUT_DIR/stage" afm-api afm-api-server +else + tar -czf "$OUTPUT_DIR/$ASSET_NAME" -C "$OUTPUT_DIR/stage" bin Package.swift Sources +fi +shasum -a 256 "$OUTPUT_DIR/$ASSET_NAME" | awk '{print $1}' > "$OUTPUT_DIR/${ASSET_NAME}.sha256" + +SHA="$(cat "$OUTPUT_DIR/${ASSET_NAME}.sha256")" + +echo "Built asset: $OUTPUT_DIR/$ASSET_NAME" +echo "SHA256: $SHA" +echo "Mode: $ASSET_MODE" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "asset_path=$OUTPUT_DIR/$ASSET_NAME" + echo "sha_path=$OUTPUT_DIR/${ASSET_NAME}.sha256" + echo "sha256=$SHA" + echo "asset_mode=$ASSET_MODE" + } >> "$GITHUB_OUTPUT" +fi diff --git a/.github/scripts/update_homebrew_tap.sh b/.github/scripts/update_homebrew_tap.sh index 59b7f6d..18f2ac9 100755 --- a/.github/scripts/update_homebrew_tap.sh +++ b/.github/scripts/update_homebrew_tap.sh @@ -3,17 +3,22 @@ set -euo pipefail RELEASE_TAG="${RELEASE_TAG:-${1:-}}" TAP_REPO_PATH="${TAP_REPO_PATH:-${2:-}}" -SOURCE_TARBALL="${SOURCE_TARBALL:-}" +RELEASE_ASSET_FILE="${RELEASE_ASSET_FILE:-}" +RELEASE_ASSET_NAME="${RELEASE_ASSET_NAME:-afm-api-macos-arm64.tar.gz}" +RELEASE_ASSET_URL="${RELEASE_ASSET_URL:-}" REPO_SLUG="${REPO_SLUG:-tankibaj/apple-foundation-model-api}" FORMULA_NAME="${FORMULA_NAME:-afm-api}" +UPDATE_VERSIONED_FORMULA="${UPDATE_VERSIONED_FORMULA:-1}" +TEMPLATE_FORMULA_PATH="${TEMPLATE_FORMULA_PATH:-}" +SYNC_TEMPLATE_ALWAYS="${SYNC_TEMPLATE_ALWAYS:-0}" if [[ -z "${RELEASE_TAG}" || -z "${TAP_REPO_PATH}" ]]; then echo "Usage: RELEASE_TAG=v1.0.2 TAP_REPO_PATH=/path/to/homebrew-tap $0" exit 1 fi -if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "ERROR: RELEASE_TAG must match v.., got: ${RELEASE_TAG}" +if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+)*$ ]]; then + echo "ERROR: RELEASE_TAG must match v..[-suffix], got: ${RELEASE_TAG}" exit 1 fi @@ -24,54 +29,82 @@ fi VERSION="${RELEASE_TAG#v}" MAJOR_MINOR="$(echo "${VERSION}" | cut -d. -f1,2)" -URL="https://github.com/${REPO_SLUG}/archive/refs/tags/${RELEASE_TAG}.tar.gz" +URL="${RELEASE_ASSET_URL:-https://github.com/${REPO_SLUG}/releases/download/${RELEASE_TAG}/${RELEASE_ASSET_NAME}}" BASE_FORMULA_PATH="${TAP_REPO_PATH}/Formula/${FORMULA_NAME}.rb" VERSIONED_FORMULA_PATH="${TAP_REPO_PATH}/Formula/${FORMULA_NAME}@${MAJOR_MINOR}.rb" +if [[ -n "${TEMPLATE_FORMULA_PATH}" && -f "${TEMPLATE_FORMULA_PATH}" ]]; then + if [[ "${SYNC_TEMPLATE_ALWAYS}" == "1" || ! -f "${BASE_FORMULA_PATH}" ]]; then + cp "${TEMPLATE_FORMULA_PATH}" "${BASE_FORMULA_PATH}" + fi +fi + if [[ ! -f "${BASE_FORMULA_PATH}" ]]; then echo "ERROR: base formula not found: ${BASE_FORMULA_PATH}" exit 1 fi -TMP_TARBALL="" -if [[ -n "${SOURCE_TARBALL}" ]]; then - if [[ ! -f "${SOURCE_TARBALL}" ]]; then - echo "ERROR: SOURCE_TARBALL not found: ${SOURCE_TARBALL}" +TMP_ASSET="" +if [[ -n "${RELEASE_ASSET_FILE}" ]]; then + if [[ ! -f "${RELEASE_ASSET_FILE}" ]]; then + echo "ERROR: RELEASE_ASSET_FILE not found: ${RELEASE_ASSET_FILE}" exit 1 fi - TMP_TARBALL="${SOURCE_TARBALL}" + TMP_ASSET="${RELEASE_ASSET_FILE}" else - TMP_TARBALL="$(mktemp -t afm-api-release-XXXXXX.tar.gz)" - curl -fsSL "${URL}" -o "${TMP_TARBALL}" + TMP_ASSET="$(mktemp -t afm-api-release-XXXXXX.tar.gz)" + curl -fsSL "${URL}" -o "${TMP_ASSET}" fi -SHA256="$(shasum -a 256 "${TMP_TARBALL}" | awk '{print $1}')" +SHA256="$(shasum -a 256 "${TMP_ASSET}" | awk '{print $1}')" + +camelize_formula_name() { + echo "$1" | awk -F'[-@.]' '{ + out="" + for (i=1; i<=NF; i++) { + if ($i == "") continue + out = out toupper(substr($i,1,1)) substr($i,2) + } + print out + }' +} -FORMULA_CLASS_BASE="AfmApi" -FORMULA_CLASS_VERSIONED="AfmApiAT${MAJOR_MINOR//./}" +FORMULA_CLASS_BASE="$(camelize_formula_name "${FORMULA_NAME}")" +FORMULA_CLASS_VERSIONED="${FORMULA_CLASS_BASE}AT${MAJOR_MINOR//./}" update_formula_file() { local file_path="$1" local class_name="$2" - ruby - "${file_path}" "${class_name}" "${URL}" "${SHA256}" <<'RUBY' -path, class_name, url, sha = ARGV + ruby - "${file_path}" "${class_name}" "${URL}" "${SHA256}" "${VERSION}" <<'RUBY' +path, class_name, url, sha, version = ARGV content = File.read(path) -content.sub!(/^class\s+\S+\s+<\s+Formula$/, "class #{class_name} < Formula") +if class_name && !class_name.empty? + content.sub!(/^class\s+\S+\s+<\s+Formula$/, "class #{class_name} < Formula") +end content.sub!(/^\s*url\s+".*"$/, " url \"#{url}\"") content.sub!(/^\s*sha256\s+".*"$/, " sha256 \"#{sha}\"") +if content.match?(/^\s*version\s+"/) + content.sub!(/^\s*version\s+".*"$/, " version \"#{version}\"") +else + content.sub!(/^\s*url\s+".*"$/, " url \"#{url}\"\n version \"#{version}\"") +end File.write(path, content) RUBY } update_formula_file "${BASE_FORMULA_PATH}" "${FORMULA_CLASS_BASE}" -if [[ ! -f "${VERSIONED_FORMULA_PATH}" ]]; then - cp "${BASE_FORMULA_PATH}" "${VERSIONED_FORMULA_PATH}" +if [[ "${UPDATE_VERSIONED_FORMULA}" == "1" ]]; then + if [[ ! -f "${VERSIONED_FORMULA_PATH}" ]]; then + cp "${BASE_FORMULA_PATH}" "${VERSIONED_FORMULA_PATH}" + fi + update_formula_file "${VERSIONED_FORMULA_PATH}" "${FORMULA_CLASS_VERSIONED}" fi -update_formula_file "${VERSIONED_FORMULA_PATH}" "${FORMULA_CLASS_VERSIONED}" echo "Updated: ${BASE_FORMULA_PATH}" -echo "Updated: ${VERSIONED_FORMULA_PATH}" +if [[ "${UPDATE_VERSIONED_FORMULA}" == "1" ]]; then + echo "Updated: ${VERSIONED_FORMULA_PATH}" +fi echo "Release: ${RELEASE_TAG}" echo "SHA256: ${SHA256}" diff --git a/.github/workflows/build-rc-pre-release.yml b/.github/workflows/build-rc-pre-release.yml new file mode 100644 index 0000000..7335cca --- /dev/null +++ b/.github/workflows/build-rc-pre-release.yml @@ -0,0 +1,96 @@ +name: Build RC Pre-release + +on: + push: + branches: + - "feature/**" + workflow_dispatch: + inputs: + release_tag: + description: "Optional RC tag override (e.g. v1.2.0-rc.feature-x)" + required: false + type: string + +permissions: + actions: write + contents: write + +jobs: + build-and-publish-rc: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve RC tag + id: vars + shell: bash + run: | + if [[ -n "${{ inputs.release_tag }}" ]]; then + TAG="${{ inputs.release_tag }}" + else + BRANCH_SLUG="$(echo "${GITHUB_REF_NAME}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" + SHORT_SHA="$(git rev-parse --short "${GITHUB_SHA}")" + TAG="v0.0.0-rc.${BRANCH_SLUG}.r${GITHUB_RUN_NUMBER}.${SHORT_SHA}" + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Build release asset + id: package + env: + RELEASE_TAG: ${{ steps.vars.outputs.tag }} + ASSET_NAME: afm-api-macos-arm64.tar.gz + run: | + ./.github/scripts/package_release_binary.sh + + - name: Publish RC pre-release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ steps.vars.outputs.tag }}" + if ! gh release view "$TAG" >/dev/null 2>&1; then + gh release create "$TAG" \ + --target "$GITHUB_SHA" \ + --title "$TAG" \ + --notes "Automated pre-release for branch ${GITHUB_REF_NAME}." \ + --prerelease + fi + + gh release edit "$TAG" --draft=false --prerelease + + gh release upload "$TAG" \ + "${{ steps.package.outputs.asset_path }}" \ + "${{ steps.package.outputs.sha_path }}" \ + --clobber + + - name: Checkout Homebrew prerelease tap + uses: actions/checkout@v4 + with: + repository: tankibaj/homebrew-tap-prerelease + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: homebrew-tap-prerelease + + - name: Update prerelease tap formula + env: + RELEASE_TAG: ${{ steps.vars.outputs.tag }} + TAP_REPO_PATH: homebrew-tap-prerelease + REPO_SLUG: tankibaj/apple-foundation-model-api + FORMULA_NAME: afm-api-rc + RELEASE_ASSET_NAME: afm-api-macos-arm64.tar.gz + UPDATE_VERSIONED_FORMULA: "0" + TEMPLATE_FORMULA_PATH: Formula/afm-api-rc.rb + SYNC_TEMPLATE_ALWAYS: "1" + run: | + ./.github/scripts/update_homebrew_tap.sh + + - name: Commit and push prerelease tap updates + working-directory: homebrew-tap-prerelease + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Formula/afm-api-rc.rb + git diff --cached --quiet && echo "No prerelease formula changes" && exit 0 + git commit -m "feat: afm-api-rc ${{ steps.vars.outputs.tag }}" + git push origin HEAD diff --git a/.github/workflows/build-release-binary.yml b/.github/workflows/build-release-binary.yml new file mode 100644 index 0000000..23c519a --- /dev/null +++ b/.github/workflows/build-release-binary.yml @@ -0,0 +1,65 @@ +name: Build Release Binary + +on: + release: + types: [published] + workflow_dispatch: + inputs: + release_tag: + description: "Release tag (e.g. v1.2.3)" + required: true + type: string + +permissions: + actions: write + contents: write + +jobs: + build-and-publish: + if: github.event_name != 'release' || github.event.release.prerelease == false + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve release tag + id: vars + run: | + if [ "${{ github.event_name }}" = "release" ]; then + TAG="${GITHUB_REF_NAME}" + else + TAG="${{ inputs.release_tag }}" + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Checkout release tag + run: | + git fetch --tags --force + git checkout "${{ steps.vars.outputs.tag }}" + + - name: Build release asset + id: package + env: + RELEASE_TAG: ${{ steps.vars.outputs.tag }} + ASSET_NAME: afm-api-macos-arm64.tar.gz + run: | + ./.github/scripts/package_release_binary.sh + + - name: Upload release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ steps.vars.outputs.tag }}" \ + "${{ steps.package.outputs.asset_path }}" \ + "${{ steps.package.outputs.sha_path }}" \ + --clobber + + - name: Trigger Homebrew tap update + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh workflow run "Update Homebrew Tap" \ + --ref main \ + -f release_tag="${{ steps.vars.outputs.tag }}" diff --git a/.github/workflows/release-on-main.yml b/.github/workflows/release-on-main.yml index ed30002..f897073 100644 --- a/.github/workflows/release-on-main.yml +++ b/.github/workflows/release-on-main.yml @@ -107,15 +107,6 @@ jobs: --title "$tag" \ --generate-notes - - name: Trigger Homebrew tap update - if: steps.version.outputs.should_release == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh workflow run "Update Homebrew Tap" \ - --ref main \ - -f release_tag="${{ steps.version.outputs.next_tag }}" - - name: Summary run: | echo "should_release=${{ steps.version.outputs.should_release }}" diff --git a/.github/workflows/update-homebrew-tap-rc.yml b/.github/workflows/update-homebrew-tap-rc.yml new file mode 100644 index 0000000..f5fb2c1 --- /dev/null +++ b/.github/workflows/update-homebrew-tap-rc.yml @@ -0,0 +1,46 @@ +name: Update Homebrew Tap RC + +on: + workflow_dispatch: + inputs: + release_tag: + description: "RC/pre-release tag (e.g. v1.2.0-rc.1)" + required: true + type: string + +jobs: + update-rc: + runs-on: macos-latest + steps: + - name: Checkout source repo + uses: actions/checkout@v4 + + - name: Checkout Homebrew tap + uses: actions/checkout@v4 + with: + repository: tankibaj/homebrew-tap-prerelease + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: homebrew-tap-prerelease + + - name: Update RC formula + env: + RELEASE_TAG: ${{ inputs.release_tag }} + TAP_REPO_PATH: homebrew-tap-prerelease + REPO_SLUG: tankibaj/apple-foundation-model-api + FORMULA_NAME: afm-api-rc + RELEASE_ASSET_NAME: afm-api-macos-arm64.tar.gz + UPDATE_VERSIONED_FORMULA: "0" + TEMPLATE_FORMULA_PATH: Formula/afm-api-rc.rb + SYNC_TEMPLATE_ALWAYS: "1" + run: | + ./.github/scripts/update_homebrew_tap.sh + + - name: Commit and push RC formula updates + working-directory: homebrew-tap-prerelease + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Formula/afm-api-rc.rb + git diff --cached --quiet && echo "No RC formula changes" && exit 0 + git commit -m "feat: afm-api-rc ${{ inputs.release_tag }}" + git push origin HEAD diff --git a/.github/workflows/update-homebrew-tap.yml b/.github/workflows/update-homebrew-tap.yml index 65cb106..bfc1d6f 100644 --- a/.github/workflows/update-homebrew-tap.yml +++ b/.github/workflows/update-homebrew-tap.yml @@ -1,8 +1,6 @@ name: Update Homebrew Tap on: - release: - types: [published] workflow_dispatch: inputs: release_tag: @@ -20,11 +18,7 @@ jobs: - name: Resolve release tag id: vars run: | - if [ "${{ github.event_name }}" = "release" ]; then - TAG="${GITHUB_REF_NAME}" - else - TAG="${{ inputs.release_tag }}" - fi + TAG="${{ inputs.release_tag }}" echo "tag=$TAG" >> "$GITHUB_OUTPUT" - name: Checkout Homebrew tap @@ -40,6 +34,7 @@ jobs: TAP_REPO_PATH: homebrew-tap REPO_SLUG: tankibaj/apple-foundation-model-api FORMULA_NAME: afm-api + RELEASE_ASSET_NAME: afm-api-macos-arm64.tar.gz run: | ./.github/scripts/update_homebrew_tap.sh diff --git a/Formula/afm-api-rc.rb b/Formula/afm-api-rc.rb new file mode 100644 index 0000000..3c7aac9 --- /dev/null +++ b/Formula/afm-api-rc.rb @@ -0,0 +1,34 @@ +class AfmApiRc < Formula + desc "OpenAI-compatible local server for Apple Foundation Model (release candidate)" + homepage "https://github.com/tankibaj/apple-foundation-model-api" + url "https://github.com/tankibaj/apple-foundation-model-api/archive/refs/tags/v1.1.1.tar.gz" + sha256 "e8b84865dc93f1aeaf0a86a9d3149254a3be3640199c6a1e1fe36bd55c7fcfa6" + license "MIT" + + depends_on :macos + + def install + if File.exist?("afm-api") && File.exist?("afm-api-server") + bin.install "afm-api" + bin.install "afm-api-server" + else + bin.install "bin/afm-api" + pkgshare.install "Package.swift" + pkgshare.install "Sources" + launcher = bin/"afm-api" + if launcher.read.include?("__AFM_API_VERSION__") + inreplace launcher, "__AFM_API_VERSION__", version.to_s + end + end + end + + test do + assert_predicate bin/"afm-api", :exist? + if (bin/"afm-api-server").exist? + assert_predicate bin/"afm-api-server", :exist? + else + assert_predicate pkgshare/"Package.swift", :exist? + assert_predicate pkgshare/"Sources/AFMAPI/main.swift", :exist? + end + end +end diff --git a/Formula/afm-api.rb b/Formula/afm-api.rb index 1f05d8b..594a408 100644 --- a/Formula/afm-api.rb +++ b/Formula/afm-api.rb @@ -1,22 +1,36 @@ class AfmApi < Formula desc "OpenAI-compatible local server for Apple Foundation Model" homepage "https://github.com/tankibaj/apple-foundation-model-api" - url "https://github.com/tankibaj/apple-foundation-model-api/archive/refs/tags/v1.0.2.tar.gz" - sha256 "5b20b82be9707c0d7f40e54d796c8466d8b7394b820cdd0e321936a9afc5cb68" + url "https://github.com/tankibaj/apple-foundation-model-api/archive/refs/tags/v1.1.1.tar.gz" + sha256 "e8b84865dc93f1aeaf0a86a9d3149254a3be3640199c6a1e1fe36bd55c7fcfa6" license "MIT" depends_on :macos def install - bin.install "bin/afm-api" - pkgshare.install "src/afm-api.swift" - - # Make the launcher reference the Homebrew-installed Swift source path. - inreplace bin/"afm-api", %r{\$SCRIPT_DIR/\.\./src/afm-api.swift}, "#{pkgshare}/afm-api.swift" + if File.exist?("afm-api") && File.exist?("afm-api-server") + # Binary release asset path (no local build required). + bin.install "afm-api" + bin.install "afm-api-server" + else + # Source release fallback. + bin.install "bin/afm-api" + pkgshare.install "Package.swift" + pkgshare.install "Sources" + launcher = bin/"afm-api" + if launcher.read.include?("__AFM_API_VERSION__") + inreplace launcher, "__AFM_API_VERSION__", version.to_s + end + end end test do assert_predicate bin/"afm-api", :exist? - assert_predicate pkgshare/"afm-api.swift", :exist? + if (bin/"afm-api-server").exist? + assert_predicate bin/"afm-api-server", :exist? + else + assert_predicate pkgshare/"Package.swift", :exist? + assert_predicate pkgshare/"Sources/AFMAPI/main.swift", :exist? + end end end diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..23842e1 --- /dev/null +++ b/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version: 5.10 +import PackageDescription + +let package = Package( + name: "AFMAPI", + platforms: [ + .macOS(.v14) + ], + products: [ + .executable(name: "afm-api-server", targets: ["AFMAPI"]) + ], + targets: [ + .executableTarget( + name: "AFMAPI", + path: "Sources/AFMAPI" + ) + ] +) diff --git a/README.md b/README.md index 8631f46..9b5a60e 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,9 @@ afm-api --background # Run in background afm-api --status # Check status afm-api --logs # View logs afm-api --stop # Stop server +afm-api --version # Show build/version +afm-api build # Build afm-api-server once (source checkout) +afm-api --rebuild # Force clean rebuild (source checkout) ``` --- @@ -209,30 +212,46 @@ afm-api --host 127.0.0.1 --port 8080 --model-name custom-model **Development mode** ```bash cd /path/to/repo +./afm-api build ./afm-api --background ./afm-api --logs --follow ``` +**SwiftPM layout (maintainers)** +```text +Package.swift +Sources/AFMAPI/openai_api +Sources/AFMAPI/capabilities +Sources/AFMAPI/models +Sources/AFMAPI/support +Sources/AFMAPI/server +``` + +**Build manually** +```bash +swift build -c release --product afm-api-server +``` + **Run tests** ```bash -./tests/test_function_call.sh +./tests/function_call.sh ``` **Function Calling Examples (real APIs)** ```bash # Country info -./tests/test_tool_country_info_restcountries.sh http://127.0.0.1:8000 +./tests/tool_country_info_restcountries.sh http://127.0.0.1:8000 # Currency conversion -./tests/test_tool_currency_frankfurter.sh http://127.0.0.1:8000 +./tests/tool_currency_frankfurter.sh http://127.0.0.1:8000 # Public holidays -./tests/test_tool_public_holidays_nager.sh http://127.0.0.1:8000 +./tests/tool_public_holidays_nager.sh http://127.0.0.1:8000 # Time zone -./tests/test_tool_timezone_worldtimeapi.sh http://127.0.0.1:8000 +./tests/tool_timezone_worldtimeapi.sh http://127.0.0.1:8000 # Weather -./tests/test_tool_weather_openmeteo.sh http://127.0.0.1:8000 +./tests/tool_weather_openmeteo.sh http://127.0.0.1:8000 ``` **Update** diff --git a/Sources/AFMAPI/capabilities/ChatCompletionsCapability.swift b/Sources/AFMAPI/capabilities/ChatCompletionsCapability.swift new file mode 100644 index 0000000..3037a1f --- /dev/null +++ b/Sources/AFMAPI/capabilities/ChatCompletionsCapability.swift @@ -0,0 +1,87 @@ +import Foundation + +func encodeJSONObjectString(_ value: Any) -> String? { + guard JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value), + let text = String(data: data, encoding: .utf8) else { + return nil + } + return text +} + +func makeToolCall(name: String, argsObj: Any) -> ToolCall { + let argsString: String + if let s = argsObj as? String { + if let data = s.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data), + let encoded = encodeJSONObjectString(json) { + argsString = encoded + } else { + argsString = "{\"value\":\(String(describing: s).debugDescription)}" + } + } else if let encoded = encodeJSONObjectString(argsObj) { + argsString = encoded + } else { + argsString = "{\"value\":\(String(describing: argsObj).debugDescription)}" + } + return ToolCall( + id: "call_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))", + type: "function", + function: ToolCallFunction(name: name, arguments: argsString) + ) +} + +func normalizeConversation(_ messages: [ChatMessage]) -> String { + var lines: [String] = [] + for m in messages { + let text = m.content ?? "" + lines.append("[\(m.role)] \(text)") + } + return lines.joined(separator: "\n") +} + +func toolsPromptBlock(_ tools: [OpenAITool], toolChoice: ToolChoice) -> String { + guard !tools.isEmpty else { + return "You have no tools. Answer directly with plain text only." + } + + let toolsJSONData = try? JSONEncoder().encode(tools) + let toolsJSON = String(data: toolsJSONData ?? Data("[]".utf8), encoding: .utf8) ?? "[]" + + var choiceLine = "Tool choice: auto." + switch toolChoice { + case .auto: + choiceLine = "Tool choice: auto." + case .none: + choiceLine = "Tool choice: none. Never call a tool." + case .named(let name): + choiceLine = "Tool choice: required tool is \(name)." + } + + return """ +You may call tools. Available tools (OpenAI schema JSON): +\(toolsJSON) +\(choiceLine) + +Respond in STRICT JSON using exactly one of these formats: +1) {"type":"final","content":""} +2) {"type":"tool_calls","tool_calls":[{"name":"","arguments":{...}}]} + +Rules: +- Emit valid JSON only, no markdown fences. +- If any tool is needed, choose type=tool_calls. +- If tool_choice is none, choose type=final. +""" +} + +func normalizeJSONTextCandidate(_ text: String) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("```") else { return trimmed } + + let lines = trimmed.split(separator: "\n", omittingEmptySubsequences: false) + guard lines.count >= 3 else { return trimmed } + guard lines.first?.hasPrefix("```") == true, lines.last == "```" else { return trimmed } + + let body = lines.dropFirst().dropLast().joined(separator: "\n") + return String(body).trimmingCharacters(in: .whitespacesAndNewlines) +} diff --git a/Sources/AFMAPI/config.swift b/Sources/AFMAPI/config.swift new file mode 100644 index 0000000..93e3703 --- /dev/null +++ b/Sources/AFMAPI/config.swift @@ -0,0 +1,42 @@ +import Foundation + +struct AppConfig { + let host: String + let port: UInt16 + let modelName: String + let apiVersion: String +} + +func parseArg(_ name: String, default value: String) -> String { + let args = CommandLine.arguments + guard let idx = args.firstIndex(of: name), idx + 1 < args.count else { return value } + return args[idx + 1] +} + +func latestAPIVersion() -> String { + return "v1" +} + +func normalizeAPIVersion(_ version: String) -> String { + let v = version.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if v == "latest" || v.isEmpty { + return latestAPIVersion() + } + if v.hasPrefix("v") { + return version + } + return "v\(version)" +} + +func parseConfig() -> AppConfig { + let host = parseArg("--host", default: "127.0.0.1") + let portStr = parseArg("--port", default: "8000") + let modelName = parseArg("--model-name", default: "apple-foundation-model") + let apiVersion = normalizeAPIVersion(parseArg("--api-version", default: latestAPIVersion())) + let port = UInt16(portStr) ?? 8000 + return AppConfig(host: host, port: port, modelName: modelName, apiVersion: apiVersion) +} + +func apiBasePath(_ version: String) -> String { + return "/\(version)" +} diff --git a/Sources/AFMAPI/main.swift b/Sources/AFMAPI/main.swift new file mode 100644 index 0000000..b02b144 --- /dev/null +++ b/Sources/AFMAPI/main.swift @@ -0,0 +1,26 @@ +import Foundation +import Network + +let cfg = parseConfig() +let processor = RequestProcessor(cfg: cfg) + +let params = NWParameters.tcp + +guard let listenerPort = NWEndpoint.Port(rawValue: cfg.port) else { + fputs("Invalid port: \(cfg.port)\n", stderr) + exit(2) +} + +let listener = try NWListener(using: params, on: listenerPort) +listener.newConnectionHandler = { connection in + let handler = ConnectionHandler(connection: connection, processor: processor) { h in + ConnectionRegistry.remove(h) + } + ConnectionRegistry.add(handler) + handler.start() +} +listener.start(queue: .main) +logLine("afm-api server listening on http://\(cfg.host):\(cfg.port)") +logLine("API version: \(cfg.apiVersion)") +logLine("Model id: \(cfg.modelName)") +RunLoop.main.run() diff --git a/Sources/AFMAPI/models/FoundationModelBridge.swift b/Sources/AFMAPI/models/FoundationModelBridge.swift new file mode 100644 index 0000000..226984e --- /dev/null +++ b/Sources/AFMAPI/models/FoundationModelBridge.swift @@ -0,0 +1,60 @@ +import Foundation +import FoundationModels + +enum BridgeRuntimeError: Error { + case unsupportedOS +} + +func runModel(input: BridgeInput) async throws -> BridgeOutput { + guard #available(macOS 26.0, *) else { + throw BridgeRuntimeError.unsupportedOS + } + + let session = LanguageModelSession(model: .default) + let prompt = """ +You are a compatibility layer for OpenAI chat completions. +\(toolsPromptBlock(input.tools, toolChoice: input.tool_choice)) + +Conversation: +\(normalizeConversation(input.messages)) +""" + + let response = try await session.respond(to: prompt) + let text = response.content + let normalized = normalizeJSONTextCandidate(text) + + if let data = normalized.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = parsed["type"] as? String { + if type == "tool_calls", let tc = parsed["tool_calls"] as? [[String: Any]] { + let mapped: [ToolCall] = tc.compactMap { entry in + guard let name = entry["name"] as? String else { return nil } + let argsObj = entry["arguments"] ?? [:] + return makeToolCall(name: name, argsObj: argsObj) + } + + if !mapped.isEmpty { + return BridgeOutput(content: nil, tool_calls: mapped, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) + } + } + + if type == "final" { + if case let .named(requiredName) = input.tool_choice { + let argsObj = parsed["content"] ?? [:] + let tc = makeToolCall(name: requiredName, argsObj: argsObj) + return BridgeOutput(content: nil, tool_calls: [tc], prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) + } + if let content = parsed["content"] as? String { + return BridgeOutput(content: content, tool_calls: nil, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) + } + if let contentObj = parsed["content"] { + if let textContent = encodeJSONObjectString(contentObj) { + return BridgeOutput(content: textContent, tool_calls: nil, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) + } + return BridgeOutput(content: String(describing: contentObj), tool_calls: nil, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) + } + } + } + + return BridgeOutput(content: text, tool_calls: nil, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) +} diff --git a/Sources/AFMAPI/openai_api/Types.swift b/Sources/AFMAPI/openai_api/Types.swift new file mode 100644 index 0000000..434c89d --- /dev/null +++ b/Sources/AFMAPI/openai_api/Types.swift @@ -0,0 +1,132 @@ +import Foundation + +struct BridgeInput: Codable { + let model: String + let messages: [ChatMessage] + let tools: [OpenAITool] + let tool_choice: ToolChoice + let temperature: Double + let max_output_tokens: Int +} + +struct ChatMessage: Codable { + let role: String + let content: String? + let name: String? + let tool_calls: [ToolCall]? +} + +struct OpenAITool: Codable { + let type: String + let function: ToolSpec +} + +struct ToolSpec: Codable { + let name: String + let description: String? + let parameters: JSONValue? +} + +struct ToolCall: Codable { + let id: String + let type: String + let function: ToolCallFunction +} + +struct ToolCallFunction: Codable { + let name: String + let arguments: String +} + +enum ToolChoice: Codable { + case auto + case none + case named(String) + + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if let s = try? c.decode(String.self) { + switch s { + case "auto": self = .auto + case "none": self = .none + default: self = .named(s) + } + return + } + let obj = try c.decode([String: JSONValue].self) + if case let .string(name)? = obj["name"] { + self = .named(name) + } else if case let .object(fn)? = obj["function"], case let .string(name)? = fn["name"] { + self = .named(name) + } else { + self = .auto + } + } + + func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .auto: try c.encode("auto") + case .none: try c.encode("none") + case .named(let name): try c.encode(["name": name]) + } + } +} + +enum JSONValue: Codable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let s = try? container.decode(String.self) { + self = .string(s) + } else if let n = try? container.decode(Double.self) { + self = .number(n) + } else if let b = try? container.decode(Bool.self) { + self = .bool(b) + } else if let o = try? container.decode([String: JSONValue].self) { + self = .object(o) + } else if let a = try? container.decode([JSONValue].self) { + self = .array(a) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let s): try container.encode(s) + case .number(let n): try container.encode(n) + case .bool(let b): try container.encode(b) + case .object(let o): try container.encode(o) + case .array(let a): try container.encode(a) + case .null: try container.encodeNil() + } + } +} + +struct BridgeOutput: Codable { + let content: String? + let tool_calls: [ToolCall]? + let prompt_tokens: Int + let completion_tokens: Int + let total_tokens: Int +} + +struct ChatCompletionsRequest: Codable { + let model: String? + let messages: [ChatMessage] + let tools: [OpenAITool]? + let tool_choice: ToolChoice? + let temperature: Double? + let max_tokens: Int? + let stream: Bool? +} diff --git a/Sources/AFMAPI/server/ConnectionHandler.swift b/Sources/AFMAPI/server/ConnectionHandler.swift new file mode 100644 index 0000000..dfcc07a --- /dev/null +++ b/Sources/AFMAPI/server/ConnectionHandler.swift @@ -0,0 +1,136 @@ +import Foundation +import Network + +final class ConnectionHandler { + private let connection: NWConnection + private let processor: RequestProcessor + private let onClose: (ConnectionHandler) -> Void + private var buffer = Data() + private var expectedBodyLength: Int? + private var closed = false + + init(connection: NWConnection, processor: RequestProcessor, onClose: @escaping (ConnectionHandler) -> Void) { + self.connection = connection + self.processor = processor + self.onClose = onClose + } + + func start() { + connection.stateUpdateHandler = { [weak self] state in + if case .ready = state { + self?.receiveLoop() + } + } + connection.start(queue: .global()) + } + + private func receiveLoop() { + connection.receive(minimumIncompleteLength: 1, maximumLength: 1024 * 1024) { [weak self] data, _, isComplete, error in + guard let self else { return } + if let data, !data.isEmpty { + self.buffer.append(data) + if self.tryProcessIfComplete() { + return + } + } + + if isComplete || error != nil { + self.close() + return + } + + self.receiveLoop() + } + } + + private func tryProcessIfComplete() -> Bool { + guard let headerRange = buffer.range(of: Data("\r\n\r\n".utf8)) else { + return false + } + + let headerData = buffer.subdata(in: 0..= 2 else { + send(status: 400, body: ["error": ["message": "Invalid request line", "type": "invalid_request_error"]]) + return true + } + + let method = String(reqParts[0]) + let path = String(reqParts[1]) + + if expectedBodyLength == nil { + expectedBodyLength = 0 + for line in lines.dropFirst() { + if line.isEmpty { continue } + let parts = line.split(separator: ":", maxSplits: 1).map(String.init) + if parts.count == 2 && parts[0].lowercased() == "content-length" { + expectedBodyLength = Int(parts[1].trimmingCharacters(in: .whitespaces)) ?? 0 + } + } + } + + let bodyStart = headerRange.upperBound + let bodyLen = buffer.count - bodyStart + let needed = expectedBodyLength ?? 0 + guard bodyLen >= needed else { + return false + } + + let body = buffer.subdata(in: bodyStart..<(bodyStart + needed)) + + var responseData = Data() + let sem = DispatchSemaphore(value: 0) + Task { + responseData = await self.processor.handle(method: method, path: path, body: body) + sem.signal() + } + sem.wait() + self.connection.send(content: responseData, completion: .contentProcessed { _ in + self.close() + }) + + return true + } + + private func send(status: Int, body: [String: Any]) { + let response = httpResponse(status: status, body: jsonData(body)) + connection.send(content: response, completion: .contentProcessed { _ in + self.close() + }) + } + + private func close() { + if closed { return } + closed = true + connection.cancel() + onClose(self) + } +} + +final class ConnectionRegistry { + private static var handlers: [ObjectIdentifier: ConnectionHandler] = [:] + private static let lock = DispatchQueue(label: "afm-api.connection.registry") + + static func add(_ handler: ConnectionHandler) { + lock.sync { + handlers[ObjectIdentifier(handler)] = handler + } + } + + static func remove(_ handler: ConnectionHandler) { + _ = lock.sync { + handlers.removeValue(forKey: ObjectIdentifier(handler)) + } + } +} diff --git a/Sources/AFMAPI/server/RequestProcessor.swift b/Sources/AFMAPI/server/RequestProcessor.swift new file mode 100644 index 0000000..951d07c --- /dev/null +++ b/Sources/AFMAPI/server/RequestProcessor.swift @@ -0,0 +1,152 @@ +import Foundation + +private let requestLogsEnabled: Bool = { + let value = ProcessInfo.processInfo.environment["AFM_API_REQUEST_LOGS"]?.lowercased() ?? "1" + return value != "0" && value != "false" && value != "off" +}() + +final class RequestProcessor { + let cfg: AppConfig + + init(cfg: AppConfig) { + self.cfg = cfg + } + + func handle(method: String, path: String, body: Data) async -> Data { + let started = Date() + func finish(_ status: Int, _ payload: Any) -> Data { + let ms = Int(Date().timeIntervalSince(started) * 1000.0) + if requestLogsEnabled { + logLine("[\(status)] \(method) \(path) \(ms)ms") + } + return httpResponse(status: status, body: jsonData(payload)) + } + + if method == "GET" && path == "/healthz" { + return finish(200, ["ok": true]) + } + + let apiBase = apiBasePath(cfg.apiVersion) + + if method == "GET" && path == "\(apiBase)" { + return finish(200, [ + "object": "api.version", + "version": cfg.apiVersion + ]) + } + + if method == "GET" && path == "\(apiBase)/health" { + do { + _ = try await runModel(input: BridgeInput( + model: cfg.modelName, + messages: [ChatMessage(role: "user", content: "ping", name: nil, tool_calls: nil)], + tools: [], + tool_choice: .none, + temperature: 0.0, + max_output_tokens: 8 + )) + return finish(200, [ + "ok": true, + "check": "model", + "model": cfg.modelName + ]) + } catch { + return finish(500, [ + "ok": false, + "check": "model", + "model": cfg.modelName, + "error": String(describing: error) + ]) + } + } + + if method == "GET" && path == "\(apiBase)/models" { + let payload: [String: Any] = [ + "object": "list", + "data": [[ + "id": cfg.modelName, + "object": "model", + "created": 0, + "owned_by": "apple" + ]] + ] + return finish(200, payload) + } + + guard method == "POST" && path == "\(apiBase)/chat/completions" else { + return finish(404, ["error": ["message": "Not found", "type": "invalid_request_error"]]) + } + + let decoder = JSONDecoder() + let req: ChatCompletionsRequest + do { + req = try decoder.decode(ChatCompletionsRequest.self, from: body) + } catch { + return finish(400, ["error": ["message": "Invalid JSON", "type": "invalid_request_error"]]) + } + + if req.stream == true { + return finish(400, ["error": ["message": "stream=true is not implemented yet", "type": "invalid_request_error"]]) + } + + if req.messages.isEmpty { + return finish(400, ["error": ["message": "messages must be a non-empty array", "type": "invalid_request_error"]]) + } + + let bridgeInput = BridgeInput( + model: req.model ?? cfg.modelName, + messages: req.messages, + tools: req.tools ?? [], + tool_choice: req.tool_choice ?? .auto, + temperature: req.temperature ?? 0.7, + max_output_tokens: req.max_tokens ?? 1024 + ) + + do { + let bridgeOutput = try await runModel(input: bridgeInput) + let completionId = "chatcmpl_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))" + let created = Int(Date().timeIntervalSince1970) + + var message: [String: Any] = ["role": "assistant"] + var finishReason = "stop" + if let toolCalls = bridgeOutput.tool_calls { + let toolCallObjs = toolCalls.map { tc in + [ + "id": tc.id, + "type": tc.type, + "function": [ + "name": tc.function.name, + "arguments": tc.function.arguments + ] + ] + } + message["tool_calls"] = toolCallObjs + message["content"] = NSNull() + finishReason = "tool_calls" + } else { + message["content"] = bridgeOutput.content ?? "" + } + + let payload: [String: Any] = [ + "id": completionId, + "object": "chat.completion", + "created": created, + "model": req.model ?? cfg.modelName, + "choices": [[ + "index": 0, + "message": message, + "finish_reason": finishReason + ]], + "usage": [ + "prompt_tokens": bridgeOutput.prompt_tokens, + "completion_tokens": bridgeOutput.completion_tokens, + "total_tokens": bridgeOutput.total_tokens + ] + ] + + return finish(200, payload) + } catch { + return finish(500, ["error": ["message": "Bridge process failed: \(error)", "type": "server_error"]]) + } + } +} diff --git a/Sources/AFMAPI/support/HTTP.swift b/Sources/AFMAPI/support/HTTP.swift new file mode 100644 index 0000000..f34ba57 --- /dev/null +++ b/Sources/AFMAPI/support/HTTP.swift @@ -0,0 +1,30 @@ +import Foundation + +func jsonData(_ obj: Any) -> Data { + guard JSONSerialization.isValidJSONObject(obj) else { + return Data("{\"error\":{\"message\":\"internal serialization error\"}}".utf8) + } + if let data = try? JSONSerialization.data(withJSONObject: obj, options: []) { + return data + } + return Data("{\"error\":{\"message\":\"internal serialization error\"}}".utf8) +} + +func httpResponse(status: Int, body: Data, contentType: String = "application/json") -> Data { + let reason: String + switch status { + case 200: reason = "OK" + case 400: reason = "Bad Request" + case 404: reason = "Not Found" + case 500: reason = "Internal Server Error" + default: reason = "OK" + } + + var head = "HTTP/1.1 \(status) \(reason)\r\n" + head += "Content-Type: \(contentType)\r\n" + head += "Content-Length: \(body.count)\r\n" + head += "Connection: close\r\n\r\n" + var data = Data(head.utf8) + data.append(body) + return data +} diff --git a/Sources/AFMAPI/support/Logging.swift b/Sources/AFMAPI/support/Logging.swift new file mode 100644 index 0000000..02f7fd0 --- /dev/null +++ b/Sources/AFMAPI/support/Logging.swift @@ -0,0 +1,108 @@ +import Foundation +import Darwin + +final class RuntimeLogger { + static let shared = RuntimeLogger() + + private let logFilePath: String? + private let logMaxBytes: Int64 + private let logMaxFiles: Int + private let stdoutIsTTY: Bool + private let queue = DispatchQueue(label: "afm-api.runtime-logger", qos: .utility) + private let fm = FileManager.default + + private var fileHandle: FileHandle? + private var fileSize: Int64 = 0 + + private init() { + self.logFilePath = ProcessInfo.processInfo.environment["AFM_API_LOG_FILE"] + self.logMaxBytes = Int64(ProcessInfo.processInfo.environment["AFM_API_LOG_MAX_BYTES"] ?? "") ?? 10 * 1024 * 1024 + let logMaxFilesRaw = Int(ProcessInfo.processInfo.environment["AFM_API_LOG_MAX_FILES"] ?? "") ?? 3 + self.logMaxFiles = max(1, logMaxFilesRaw) + self.stdoutIsTTY = isatty(fileno(stdout)) == 1 + + guard let path = logFilePath, !path.isEmpty else { return } + openHandle(path: path) + } + + deinit { + try? fileHandle?.close() + } + + func log(_ message: String) { + guard stdoutIsTTY || (logFilePath?.isEmpty == false) else { return } + queue.async { [self] in + if stdoutIsTTY { + fputs(message + "\n", stdout) + } + guard let path = logFilePath, !path.isEmpty else { return } + guard let data = (message + "\n").data(using: .utf8) else { return } + + rotateIfNeeded(path: path, incomingBytes: Int64(data.count)) + if fileHandle == nil { + openHandle(path: path) + } + guard let handle = fileHandle else { return } + + do { + _ = try handle.seekToEnd() + try handle.write(contentsOf: data) + fileSize += Int64(data.count) + } catch { + try? handle.close() + fileHandle = nil + } + } + } + + private func openHandle(path: String) { + let url = URL(fileURLWithPath: path) + let dir = url.deletingLastPathComponent().path + try? fm.createDirectory(atPath: dir, withIntermediateDirectories: true) + + if !fm.fileExists(atPath: path) { + fm.createFile(atPath: path, contents: nil) + } + + if let attrs = try? fm.attributesOfItem(atPath: path), + let sizeNum = attrs[.size] as? NSNumber { + fileSize = sizeNum.int64Value + } else { + fileSize = 0 + } + + do { + fileHandle = try FileHandle(forWritingTo: url) + _ = try fileHandle?.seekToEnd() + } catch { + fileHandle = nil + } + } + + private func rotateIfNeeded(path: String, incomingBytes: Int64) { + guard logMaxBytes > 0 else { return } + guard fileSize + incomingBytes >= logMaxBytes else { return } + + try? fileHandle?.close() + fileHandle = nil + + for idx in stride(from: logMaxFiles - 1, through: 0, by: -1) { + let src = idx == 0 ? path : "\(path).\(idx)" + let dst = "\(path).\(idx + 1)" + + guard fm.fileExists(atPath: src) else { continue } + if fm.fileExists(atPath: dst) { + try? fm.removeItem(atPath: dst) + } + try? fm.moveItem(atPath: src, toPath: dst) + } + + fm.createFile(atPath: path, contents: nil) + fileSize = 0 + openHandle(path: path) + } +} + +func logLine(_ message: String) { + RuntimeLogger.shared.log(message) +} diff --git a/bin/afm-api b/bin/afm-api index cbf76d1..8d7e41e 100755 --- a/bin/afm-api +++ b/bin/afm-api @@ -2,18 +2,203 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -export SWIFT_MODULECACHE_PATH="${SWIFT_MODULECACHE_PATH:-/tmp/afm-api-swift-module-cache}" -mkdir -p "$SWIFT_MODULECACHE_PATH" + +if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BLUE='\033[0;34m' + NC='\033[0m' +else + RED='' + GREEN='' + YELLOW='' + BLUE='' + NC='' +fi + +info() { echo -e "${BLUE}ℹ️ $*${NC}"; } +ok() { echo -e "${GREEN}✅ $*${NC}"; } +warn() { echo -e "${YELLOW}⚠️ $*${NC}"; } +fail() { echo -e "${RED}❌ $*${NC}" >&2; exit 1; } + +version_ge() { + local current="$1" + local required="$2" + [[ "$(printf '%s\n%s\n' "$required" "$current" | sort -V | tail -n1)" == "$current" ]] +} + +check_macos_version() { + [[ "$(uname -s)" == "Darwin" ]] || fail "afm-api only runs on macOS." + + local required="26.0" + local detected + detected="$(sw_vers -productVersion 2>/dev/null || true)" + [[ -n "$detected" ]] || fail "Unable to read macOS version via sw_vers." + + info "Detected macOS $detected" + if version_ge "$detected" "$required"; then + ok "macOS version check passed (required >= $required)." + else + fail "macOS $detected is not supported. Required >= $required." + fi +} + +check_swift_toolchain() { + command -v swift >/dev/null 2>&1 || fail "Swift compiler not found. Install Xcode Command Line Tools." + + local output swift_version + output="$(swift --version 2>&1 || true)" + swift_version="$(printf '%s\n' "$output" | sed -nE 's/.*Apple Swift version ([0-9]+\.[0-9]+).*/\1/p' | head -n1)" + + if [[ -z "$swift_version" ]]; then + warn "Could not parse Swift version from 'swift --version'. Continuing." + return + fi + + info "Detected Swift $swift_version" + if version_ge "$swift_version" "6.0"; then + ok "Swift toolchain check passed." + else + warn "Swift $swift_version may be older than recommended (6.0+). Continuing." + fi +} + +check_xcode_clt() { + command -v xcode-select >/dev/null 2>&1 || fail "xcode-select not found. Install Xcode Command Line Tools: xcode-select --install" + + local dev_dir + dev_dir="$(xcode-select -p 2>/dev/null || true)" + if [[ -n "$dev_dir" ]]; then + ok "Xcode Command Line Tools detected at $dev_dir." + else + fail "Xcode Command Line Tools not configured. Run: xcode-select --install" + fi +} + +validate_build_prereqs() { + check_macos_version + check_swift_toolchain + check_xcode_clt +} RUNTIME_DIR="${AFM_API_RUNTIME_DIR:-/tmp/afm-api}" PID_FILE="$RUNTIME_DIR/afm-api.pid" LOG_FILE="$RUNTIME_DIR/afm-api.log" +LOG_MAX_BYTES="${AFM_API_LOG_MAX_BYTES:-10485760}" # 10MB +LOG_MAX_FILES="${AFM_API_LOG_MAX_FILES:-3}" mkdir -p "$RUNTIME_DIR" + export AFM_API_LOG_FILE="$LOG_FILE" +export AFM_API_LOG_MAX_BYTES="$LOG_MAX_BYTES" +export AFM_API_LOG_MAX_FILES="$LOG_MAX_FILES" DEFAULT_HOST="${AFM_API_HOST:-127.0.0.1}" DEFAULT_PORT="${AFM_API_PORT:-8000}" DEFAULT_API_VERSION="${AFM_API_VERSION:-latest}" +DEFAULT_SOURCE_ROOT="${AFM_API_SOURCE_ROOT:-}" +EMBEDDED_VERSION="__AFM_API_VERSION__" + +resolve_source_root() { + if [[ -n "$DEFAULT_SOURCE_ROOT" && -f "$DEFAULT_SOURCE_ROOT/Package.swift" ]]; then + echo "$DEFAULT_SOURCE_ROOT" + return + fi + + if [[ -f "$SCRIPT_DIR/../Package.swift" ]]; then + echo "$SCRIPT_DIR/.." + return + fi + + if [[ -f "$SCRIPT_DIR/../share/afm-api/Package.swift" ]]; then + echo "$SCRIPT_DIR/../share/afm-api" + return + fi + + echo "" +} + +SOURCE_ROOT="$(resolve_source_root)" +if [[ -n "$SOURCE_ROOT" ]]; then + BUILD_DIR="${AFM_API_BUILD_DIR:-$SOURCE_ROOT/.build}" +else + BUILD_DIR="${AFM_API_BUILD_DIR:-$RUNTIME_DIR/build}" +fi + +resolve_version() { + # When packaged, EMBEDDED_VERSION is replaced with a concrete version string. + # Keep this check resilient to in-place replacement by avoiding exact placeholder literals. + if [[ -n "$EMBEDDED_VERSION" && "$EMBEDDED_VERSION" != *"AFM_API_VERSION"* ]]; then + echo "$EMBEDDED_VERSION" + return + fi + + if [[ -n "$SOURCE_ROOT" && -d "$SOURCE_ROOT/.git" ]] && command -v git >/dev/null 2>&1; then + local git_version + git_version="$(git -C "$SOURCE_ROOT" describe --tags --always --dirty 2>/dev/null || true)" + if [[ -n "$git_version" ]]; then + echo "$git_version" + return + fi + fi + + if command -v brew >/dev/null 2>&1; then + local brew_version + brew_version="$(brew list --versions afm-api 2>/dev/null | awk '{print $2}' | head -n1 || true)" + if [[ -z "$brew_version" ]]; then + brew_version="$(brew list --versions afm-api-rc 2>/dev/null | awk '{print $2}' | head -n1 || true)" + fi + if [[ -n "$brew_version" ]]; then + echo "$brew_version" + return + fi + fi + + echo "unknown" +} + +resolve_server_bin() { + if [[ -n "${AFM_API_SERVER_BIN:-}" && -x "${AFM_API_SERVER_BIN}" ]]; then + echo "${AFM_API_SERVER_BIN}" + return + fi + + if [[ -x "$SCRIPT_DIR/afm-api-server" ]]; then + echo "$SCRIPT_DIR/afm-api-server" + return + fi + + if [[ -n "$SOURCE_ROOT" && -x "$SOURCE_ROOT/.build/release/afm-api-server" ]]; then + echo "$SOURCE_ROOT/.build/release/afm-api-server" + return + fi + + if [[ -x "$BUILD_DIR/release/afm-api-server" ]]; then + echo "$BUILD_DIR/release/afm-api-server" + return + fi + + echo "" +} + +build_server() { + local clean="${1:-0}" + [[ -n "$SOURCE_ROOT" ]] || fail "Source checkout not found. 'afm-api build' requires Package.swift." + + validate_build_prereqs + + if [[ "$clean" == "1" ]]; then + rm -rf "$BUILD_DIR" + fi + + info "Building afm-api-server (release)..." + swift build \ + --package-path "$SOURCE_ROOT" \ + --scratch-path "$BUILD_DIR" \ + -c release \ + --product afm-api-server + ok "Build complete." +} is_running() { if [[ ! -f "$PID_FILE" ]]; then @@ -42,11 +227,21 @@ has_arg() { return 1 } +subcommand="${1:-}" +explicit_build=false +if [[ "$subcommand" == "build" ]]; then + explicit_build=true + shift +fi + background=false stop=false status=false logs=false follow=false +rebuild=false +show_version=false +clean_build=false passthrough=() while [[ $# -gt 0 ]]; do @@ -66,6 +261,15 @@ while [[ $# -gt 0 ]]; do --follow|-f) follow=true ;; + --rebuild) + rebuild=true + ;; + --version|-v) + show_version=true + ;; + --clean) + clean_build=true + ;; *) passthrough+=("$1") ;; @@ -73,6 +277,11 @@ while [[ $# -gt 0 ]]; do shift done +if [[ "$show_version" == true ]]; then + echo "afm-api $(resolve_version)" + exit 0 +fi + if [[ "$stop" == true ]]; then if is_running; then pid="$(cat "$PID_FILE")" @@ -115,6 +324,15 @@ if [[ "$logs" == true ]]; then exit 0 fi +if [[ "$explicit_build" == true ]]; then + build_server "$([[ "$clean_build" == true ]] && echo 1 || echo 0)" + exit 0 +fi + +if [[ "$rebuild" == true ]]; then + build_server 1 +fi + if ! has_arg "--host" "${passthrough[@]-}"; then passthrough+=("--host" "$DEFAULT_HOST") fi @@ -125,7 +343,12 @@ if ! has_arg "--api-version" "${passthrough[@]-}"; then passthrough+=("--api-version" "$DEFAULT_API_VERSION") fi -cmd=(swift -module-cache-path "$SWIFT_MODULECACHE_PATH" "$SCRIPT_DIR/../src/afm-api.swift") +SERVER_BIN="$(resolve_server_bin)" +if [[ -z "$SERVER_BIN" ]]; then + fail "afm-api-server binary not found. Run 'afm-api build' (source checkout) or reinstall via Homebrew." +fi + +cmd=("$SERVER_BIN") if [[ "$background" == true ]]; then if is_running; then diff --git a/docs/homebrew-local-testing.md b/docs/homebrew-local-testing.md new file mode 100644 index 0000000..a69b581 --- /dev/null +++ b/docs/homebrew-local-testing.md @@ -0,0 +1,65 @@ +# Local Homebrew Testing + +This guide explains how to test `afm-api` from your current local branch without merging into `main`. + +## Quick Start + +From the repo root: + +```bash +./tests/homebrew_feature_branch_install.sh +``` + +Default tap used by the test script: + +- `tankibaj/localtap` + +## What The Test Does + +1. Creates a tarball from current `HEAD`. +2. Creates/uses the local tap (`tankibaj/localtap` by default). +3. Writes a temporary formula for `afm-api`. +4. Runs: + - `brew reinstall tankibaj/localtap/afm-api` +5. Runs a smoke test: + - `afm-api --background` + - `curl http://127.0.0.1:8000/v1/health` + - `afm-api --stop` +6. Cleans up temporary artifacts by default. + +## Optional Flags + +Keep installed formula: + +```bash +KEEP_INSTALL=1 ./tests/homebrew_feature_branch_install.sh +``` + +Keep local tap: + +```bash +KEEP_TAP=1 ./tests/homebrew_feature_branch_install.sh +``` + +Use a custom local tap: + +```bash +./tests/homebrew_feature_branch_install.sh myuser/localtap +``` + +## Manual Cleanup + +```bash +afm-api --stop || true +brew uninstall afm-api || true +brew uninstall tankibaj/localtap/afm-api || true +brew untap tankibaj/localtap || true +rm -f /tmp/afm-api-feature-*.tar.gz +rm -rf /tmp/afm-api +``` + +Optional cleanup for published tap install: + +```bash +brew untap tankibaj/tap || true +``` diff --git a/src/afm-api.swift b/src/afm-api.swift deleted file mode 100755 index ee28354..0000000 --- a/src/afm-api.swift +++ /dev/null @@ -1,667 +0,0 @@ -#!/usr/bin/env swift - -import Foundation -import FoundationModels -import Network -import Darwin - -let logFilePath = ProcessInfo.processInfo.environment["AFM_API_LOG_FILE"] -let logFileLock = NSLock() -let stdoutIsTTY = isatty(fileno(stdout)) == 1 - -func logLine(_ message: String) { - if stdoutIsTTY { - print(message) - } - guard let path = logFilePath, !path.isEmpty else { return } - logFileLock.lock() - defer { logFileLock.unlock() } - let line = message + "\n" - guard let data = line.data(using: .utf8) else { return } - if FileManager.default.fileExists(atPath: path) { - if let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: path)) { - defer { try? handle.close() } - _ = try? handle.seekToEnd() - try? handle.write(contentsOf: data) - } - } else { - FileManager.default.createFile(atPath: path, contents: data) - } -} - -struct BridgeInput: Codable { - let model: String - let messages: [ChatMessage] - let tools: [OpenAITool] - let tool_choice: ToolChoice - let temperature: Double - let max_output_tokens: Int -} - -struct ChatMessage: Codable { - let role: String - let content: String? - let name: String? - let tool_calls: [ToolCall]? -} - -struct OpenAITool: Codable { - let type: String - let function: ToolSpec -} - -struct ToolSpec: Codable { - let name: String - let description: String? - let parameters: JSONValue? -} - -struct ToolCall: Codable { - let id: String - let type: String - let function: ToolCallFunction -} - -struct ToolCallFunction: Codable { - let name: String - let arguments: String -} - -enum ToolChoice: Codable { - case auto - case none - case named(String) - - init(from decoder: Decoder) throws { - let c = try decoder.singleValueContainer() - if let s = try? c.decode(String.self) { - switch s { - case "auto": self = .auto - case "none": self = .none - default: self = .named(s) - } - return - } - let obj = try c.decode([String: JSONValue].self) - if case let .string(name)? = obj["name"] { - self = .named(name) - } else if case let .object(fn)? = obj["function"], case let .string(name)? = fn["name"] { - self = .named(name) - } else { - self = .auto - } - } - - func encode(to encoder: Encoder) throws { - var c = encoder.singleValueContainer() - switch self { - case .auto: try c.encode("auto") - case .none: try c.encode("none") - case .named(let name): try c.encode(["name": name]) - } - } -} - -enum JSONValue: Codable { - case string(String) - case number(Double) - case bool(Bool) - case object([String: JSONValue]) - case array([JSONValue]) - case null - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if container.decodeNil() { - self = .null - } else if let s = try? container.decode(String.self) { - self = .string(s) - } else if let n = try? container.decode(Double.self) { - self = .number(n) - } else if let b = try? container.decode(Bool.self) { - self = .bool(b) - } else if let o = try? container.decode([String: JSONValue].self) { - self = .object(o) - } else if let a = try? container.decode([JSONValue].self) { - self = .array(a) - } else { - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value") - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .string(let s): try container.encode(s) - case .number(let n): try container.encode(n) - case .bool(let b): try container.encode(b) - case .object(let o): try container.encode(o) - case .array(let a): try container.encode(a) - case .null: try container.encodeNil() - } - } -} - -struct BridgeOutput: Codable { - let content: String? - let tool_calls: [ToolCall]? - let prompt_tokens: Int - let completion_tokens: Int - let total_tokens: Int -} - -func encodeJSONObjectString(_ value: Any) -> String? { - guard JSONSerialization.isValidJSONObject(value), - let data = try? JSONSerialization.data(withJSONObject: value), - let text = String(data: data, encoding: .utf8) else { - return nil - } - return text -} - -func makeToolCall(name: String, argsObj: Any) -> ToolCall { - let argsString: String - if let s = argsObj as? String { - if let data = s.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data), - let encoded = encodeJSONObjectString(json) { - argsString = encoded - } else { - argsString = "{\"value\":\(String(describing: s).debugDescription)}" - } - } else if let encoded = encodeJSONObjectString(argsObj) { - argsString = encoded - } else { - argsString = "{\"value\":\(String(describing: argsObj).debugDescription)}" - } - return ToolCall( - id: "call_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))", - type: "function", - function: ToolCallFunction(name: name, arguments: argsString) - ) -} - -struct ChatCompletionsRequest: Codable { - let model: String? - let messages: [ChatMessage] - let tools: [OpenAITool]? - let tool_choice: ToolChoice? - let temperature: Double? - let max_tokens: Int? - let stream: Bool? -} - -struct AppConfig { - let host: String - let port: UInt16 - let modelName: String - let apiVersion: String -} - -func parseArg(_ name: String, default value: String) -> String { - let args = CommandLine.arguments - guard let idx = args.firstIndex(of: name), idx + 1 < args.count else { return value } - return args[idx + 1] -} - -func latestAPIVersion() -> String { - return "v1" -} - -func normalizeAPIVersion(_ version: String) -> String { - let v = version.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if v == "latest" || v.isEmpty { - return latestAPIVersion() - } - if v.hasPrefix("v") { - return version - } - return "v\(version)" -} - -func parseConfig() -> AppConfig { - let host = parseArg("--host", default: "127.0.0.1") - let portStr = parseArg("--port", default: "8000") - let modelName = parseArg("--model-name", default: "apple-foundation-model") - let apiVersion = normalizeAPIVersion(parseArg("--api-version", default: latestAPIVersion())) - let port = UInt16(portStr) ?? 8000 - return AppConfig(host: host, port: port, modelName: modelName, apiVersion: apiVersion) -} - -func apiBasePath(_ version: String) -> String { - return "/\(version)" -} - -func normalizeConversation(_ messages: [ChatMessage]) -> String { - var lines: [String] = [] - for m in messages { - let text = m.content ?? "" - lines.append("[\(m.role)] \(text)") - } - return lines.joined(separator: "\n") -} - -func toolsPromptBlock(_ tools: [OpenAITool], toolChoice: ToolChoice) -> String { - guard !tools.isEmpty else { - return "You have no tools. Answer directly with plain text only." - } - - let toolsJSONData = try? JSONEncoder().encode(tools) - let toolsJSON = String(data: toolsJSONData ?? Data("[]".utf8), encoding: .utf8) ?? "[]" - - var choiceLine = "Tool choice: auto." - switch toolChoice { - case .auto: - choiceLine = "Tool choice: auto." - case .none: - choiceLine = "Tool choice: none. Never call a tool." - case .named(let name): - choiceLine = "Tool choice: required tool is \(name)." - } - - return """ -You may call tools. Available tools (OpenAI schema JSON): -\(toolsJSON) -\(choiceLine) - -Respond in STRICT JSON using exactly one of these formats: -1) {"type":"final","content":""} -2) {"type":"tool_calls","tool_calls":[{"name":"","arguments":{...}}]} - -Rules: -- Emit valid JSON only, no markdown fences. -- If any tool is needed, choose type=tool_calls. -- If tool_choice is none, choose type=final. -""" -} - -func normalizeJSONTextCandidate(_ text: String) -> String { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasPrefix("```") else { return trimmed } - - let lines = trimmed.split(separator: "\n", omittingEmptySubsequences: false) - guard lines.count >= 3 else { return trimmed } - guard lines.first?.hasPrefix("```") == true, lines.last == "```" else { return trimmed } - - let body = lines.dropFirst().dropLast().joined(separator: "\n") - return String(body).trimmingCharacters(in: .whitespacesAndNewlines) -} - -func runModel(input: BridgeInput) async throws -> BridgeOutput { - let session = LanguageModelSession(model: .default) - - let prompt = """ -You are a compatibility layer for OpenAI chat completions. -\(toolsPromptBlock(input.tools, toolChoice: input.tool_choice)) - -Conversation: -\(normalizeConversation(input.messages)) -""" - - let response = try await session.respond(to: prompt) - let text = response.content - let normalized = normalizeJSONTextCandidate(text) - - if let data = normalized.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let type = parsed["type"] as? String { - if type == "tool_calls", let tc = parsed["tool_calls"] as? [[String: Any]] { - let mapped: [ToolCall] = tc.compactMap { entry in - guard let name = entry["name"] as? String else { return nil } - let argsObj = entry["arguments"] ?? [:] - return makeToolCall(name: name, argsObj: argsObj) - } - - if !mapped.isEmpty { - return BridgeOutput(content: nil, tool_calls: mapped, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) - } - } - - if type == "final" { - if case let .named(requiredName) = input.tool_choice { - let argsObj = parsed["content"] ?? [:] - let tc = makeToolCall(name: requiredName, argsObj: argsObj) - return BridgeOutput(content: nil, tool_calls: [tc], prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) - } - if let content = parsed["content"] as? String { - return BridgeOutput(content: content, tool_calls: nil, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) - } - if let contentObj = parsed["content"] { - if let textContent = encodeJSONObjectString(contentObj) { - return BridgeOutput(content: textContent, tool_calls: nil, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) - } - return BridgeOutput(content: String(describing: contentObj), tool_calls: nil, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) - } - } - } - - return BridgeOutput(content: text, tool_calls: nil, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) -} - -func jsonData(_ obj: Any) -> Data { - if let data = try? JSONSerialization.data(withJSONObject: obj, options: []) { - return data - } - return Data("{\"error\":{\"message\":\"internal serialization error\"}}".utf8) -} - -func httpResponse(status: Int, body: Data, contentType: String = "application/json") -> Data { - let reason: String - switch status { - case 200: reason = "OK" - case 400: reason = "Bad Request" - case 404: reason = "Not Found" - case 500: reason = "Internal Server Error" - default: reason = "OK" - } - - var head = "HTTP/1.1 \(status) \(reason)\r\n" - head += "Content-Type: \(contentType)\r\n" - head += "Content-Length: \(body.count)\r\n" - head += "Connection: close\r\n\r\n" - var data = Data(head.utf8) - data.append(body) - return data -} - -final class RequestProcessor { - let cfg: AppConfig - - init(cfg: AppConfig) { - self.cfg = cfg - } - - func handle(method: String, path: String, body: Data) async -> Data { - let started = Date() - func finish(_ status: Int, _ payload: Any) -> Data { - let ms = Int(Date().timeIntervalSince(started) * 1000.0) - logLine("[\(status)] \(method) \(path) \(ms)ms") - return httpResponse(status: status, body: jsonData(payload)) - } - - if method == "GET" && path == "/healthz" { - return finish(200, ["ok": true]) - } - - let apiBase = apiBasePath(cfg.apiVersion) - - if method == "GET" && path == "\(apiBase)" { - return finish(200, [ - "object": "api.version", - "version": cfg.apiVersion - ]) - } - - if method == "GET" && path == "\(apiBase)/health" { - do { - _ = try await runModel(input: BridgeInput( - model: cfg.modelName, - messages: [ChatMessage(role: "user", content: "ping", name: nil, tool_calls: nil)], - tools: [], - tool_choice: .none, - temperature: 0.0, - max_output_tokens: 8 - )) - return finish(200, [ - "ok": true, - "check": "model", - "model": cfg.modelName - ]) - } catch { - return finish(500, [ - "ok": false, - "check": "model", - "model": cfg.modelName, - "error": String(describing: error) - ]) - } - } - - if method == "GET" && path == "\(apiBase)/models" { - let payload: [String: Any] = [ - "object": "list", - "data": [[ - "id": cfg.modelName, - "object": "model", - "created": 0, - "owned_by": "apple" - ]] - ] - return finish(200, payload) - } - - guard method == "POST" && path == "\(apiBase)/chat/completions" else { - return finish(404, ["error": ["message": "Not found", "type": "invalid_request_error"]]) - } - - let decoder = JSONDecoder() - let req: ChatCompletionsRequest - do { - req = try decoder.decode(ChatCompletionsRequest.self, from: body) - } catch { - return finish(400, ["error": ["message": "Invalid JSON", "type": "invalid_request_error"]]) - } - - if req.stream == true { - return finish(400, ["error": ["message": "stream=true is not implemented yet", "type": "invalid_request_error"]]) - } - - if req.messages.isEmpty { - return finish(400, ["error": ["message": "messages must be a non-empty array", "type": "invalid_request_error"]]) - } - - let bridgeInput = BridgeInput( - model: req.model ?? cfg.modelName, - messages: req.messages, - tools: req.tools ?? [], - tool_choice: req.tool_choice ?? .auto, - temperature: req.temperature ?? 0.7, - max_output_tokens: req.max_tokens ?? 1024 - ) - - do { - let bridgeOutput = try await runModel(input: bridgeInput) - let completionId = "chatcmpl_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))" - let created = Int(Date().timeIntervalSince1970) - - var message: [String: Any] = ["role": "assistant"] - var finishReason = "stop" - if let toolCalls = bridgeOutput.tool_calls { - let toolCallObjs = toolCalls.map { tc in - [ - "id": tc.id, - "type": tc.type, - "function": [ - "name": tc.function.name, - "arguments": tc.function.arguments - ] - ] - } - message["tool_calls"] = toolCallObjs - message["content"] = NSNull() - finishReason = "tool_calls" - } else { - message["content"] = bridgeOutput.content ?? "" - } - - let payload: [String: Any] = [ - "id": completionId, - "object": "chat.completion", - "created": created, - "model": req.model ?? cfg.modelName, - "choices": [[ - "index": 0, - "message": message, - "finish_reason": finishReason - ]], - "usage": [ - "prompt_tokens": bridgeOutput.prompt_tokens, - "completion_tokens": bridgeOutput.completion_tokens, - "total_tokens": bridgeOutput.total_tokens - ] - ] - - return finish(200, payload) - } catch { - return finish(500, ["error": ["message": "Bridge process failed: \(error)", "type": "server_error"]]) - } - } -} - -final class ConnectionHandler { - private let connection: NWConnection - private let processor: RequestProcessor - private let onClose: (ConnectionHandler) -> Void - private var buffer = Data() - private var expectedBodyLength: Int? - private var closed = false - - init(connection: NWConnection, processor: RequestProcessor, onClose: @escaping (ConnectionHandler) -> Void) { - self.connection = connection - self.processor = processor - self.onClose = onClose - } - - func start() { - connection.stateUpdateHandler = { [weak self] state in - if case .ready = state { - self?.receiveLoop() - } - } - connection.start(queue: .global()) - } - - private func receiveLoop() { - connection.receive(minimumIncompleteLength: 1, maximumLength: 1024 * 1024) { [weak self] data, _, isComplete, error in - guard let self else { return } - if let data, !data.isEmpty { - self.buffer.append(data) - if self.tryProcessIfComplete() { - return - } - } - - if isComplete || error != nil { - self.close() - return - } - - self.receiveLoop() - } - } - - private func tryProcessIfComplete() -> Bool { - guard let headerRange = buffer.range(of: Data("\r\n\r\n".utf8)) else { - return false - } - - let headerData = buffer.subdata(in: 0..= 2 else { - send(status: 400, body: ["error": ["message": "Invalid request line", "type": "invalid_request_error"]]) - return true - } - - let method = String(reqParts[0]) - let path = String(reqParts[1]) - - if expectedBodyLength == nil { - expectedBodyLength = 0 - for line in lines.dropFirst() { - if line.isEmpty { continue } - let parts = line.split(separator: ":", maxSplits: 1).map(String.init) - if parts.count == 2 && parts[0].lowercased() == "content-length" { - expectedBodyLength = Int(parts[1].trimmingCharacters(in: .whitespaces)) ?? 0 - } - } - } - - let bodyStart = headerRange.upperBound - let bodyLen = buffer.count - bodyStart - let needed = expectedBodyLength ?? 0 - guard bodyLen >= needed else { - return false - } - - let body = buffer.subdata(in: bodyStart..<(bodyStart + needed)) - - var responseData = Data() - let sem = DispatchSemaphore(value: 0) - Task { - responseData = await self.processor.handle(method: method, path: path, body: body) - sem.signal() - } - sem.wait() - self.connection.send(content: responseData, completion: .contentProcessed { _ in - self.close() - }) - - return true - } - - private func send(status: Int, body: [String: Any]) { - let response = httpResponse(status: status, body: jsonData(body)) - connection.send(content: response, completion: .contentProcessed { _ in - self.close() - }) - } - - private func close() { - if closed { return } - closed = true - connection.cancel() - onClose(self) - } -} - -final class ConnectionRegistry { - private static var handlers: [ObjectIdentifier: ConnectionHandler] = [:] - private static let lock = DispatchQueue(label: "afm-api.connection.registry") - - static func add(_ handler: ConnectionHandler) { - lock.sync { - handlers[ObjectIdentifier(handler)] = handler - } - } - - static func remove(_ handler: ConnectionHandler) { - lock.sync { - handlers.removeValue(forKey: ObjectIdentifier(handler)) - } - } -} - -let cfg = parseConfig() -let processor = RequestProcessor(cfg: cfg) - -let params = NWParameters.tcp - -guard let listenerPort = NWEndpoint.Port(rawValue: cfg.port) else { - fputs("Invalid port: \(cfg.port)\n", stderr) - exit(2) -} - -let listener = try NWListener(using: params, on: listenerPort) -listener.newConnectionHandler = { connection in - let handler = ConnectionHandler(connection: connection, processor: processor) { h in - ConnectionRegistry.remove(h) - } - ConnectionRegistry.add(handler) - handler.start() -} -listener.start(queue: .main) -logLine("afm-api server listening on http://\(cfg.host):\(cfg.port)") -logLine("API version: \(cfg.apiVersion)") -logLine("Model id: \(cfg.modelName)") -RunLoop.main.run() diff --git a/tests/completion_and_tool_calling.sh b/tests/completion_and_tool_calling.sh new file mode 100755 index 0000000..46e6a67 --- /dev/null +++ b/tests/completion_and_tool_calling.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -euo pipefail + +require() { + command -v "$1" >/dev/null 2>&1 || { + echo "FAIL: missing command: $1" + exit 1 + } +} + +require curl +require jq + +BASE_URL="${1:-http://127.0.0.1:8000}" +API_VERSION="${API_VERSION:-v1}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +AFM_API_BIN="${AFM_API_BIN:-$REPO_ROOT/bin/afm-api}" +RUNTIME_DIR="/tmp/afm-api-smoke" +STARTED_SERVER=0 + +extract_host() { + echo "$1" | sed -E 's#^https?://([^:/]+).*$#\1#' +} + +extract_port() { + local p + p="$(echo "$1" | sed -nE 's#^https?://[^:/]+:([0-9]+).*$#\1#p')" + if [[ -n "$p" ]]; then + echo "$p" + else + echo "8000" + fi +} + +cleanup() { + if [[ "$STARTED_SERVER" == "1" ]]; then + AFM_API_RUNTIME_DIR="$RUNTIME_DIR" "$AFM_API_BIN" --stop >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +if ! curl -sf "$BASE_URL/$API_VERSION/health" >/dev/null 2>&1; then + if [[ ! -x "$AFM_API_BIN" ]]; then + echo "FAIL: server is not reachable at $BASE_URL and no local afm-api launcher found at $AFM_API_BIN" + exit 1 + fi + + HOST="$(extract_host "$BASE_URL")" + PORT="$(extract_port "$BASE_URL")" + AFM_API_RUNTIME_DIR="$RUNTIME_DIR" AFM_API_SOURCE_ROOT="$REPO_ROOT" "$AFM_API_BIN" --background --host "$HOST" --port "$PORT" >/dev/null + STARTED_SERVER=1 + + for _ in $(seq 1 120); do + if curl -sf "$BASE_URL/$API_VERSION/health" >/dev/null 2>&1; then + break + fi + sleep 0.1 + done +fi + +MODEL_ID="$(curl -s "$BASE_URL/$API_VERSION/models" | jq -r '.data[0].id // empty')" +if [[ -z "$MODEL_ID" ]]; then + echo "FAIL: could not resolve model id from $BASE_URL/$API_VERSION/models" + exit 1 +fi + +completion_payload="$(jq -nc --arg model "$MODEL_ID" '{model:$model,messages:[{role:"user",content:"Reply with one word: hello"}],temperature:0}')" +completion_resp="$(curl -s "$BASE_URL/$API_VERSION/chat/completions" -H 'content-type: application/json' -d "$completion_payload")" +completion_text="$(jq -r '.choices[0].message.content // empty' <<<"$completion_resp")" +if [[ -z "$completion_text" ]]; then + echo "FAIL: completion returned empty text" + echo "$completion_resp" | jq + exit 1 +fi + +first_tool_payload="$(jq -nc --arg model "$MODEL_ID" '{model:$model,messages:[{role:"user",content:"Use get_weather for Berlin."}],tools:[{type:"function",function:{name:"get_weather",description:"Get weather by city",parameters:{type:"object",properties:{city:{type:"string"}},required:["city"]}}}],tool_choice:{type:"function",function:{name:"get_weather"}}}')" +first_tool_resp="$(curl -s "$BASE_URL/$API_VERSION/chat/completions" -H 'content-type: application/json' -d "$first_tool_payload")" + +finish_reason="$(jq -r '.choices[0].finish_reason // empty' <<<"$first_tool_resp")" +tool_name="$(jq -r '.choices[0].message.tool_calls[0].function.name // empty' <<<"$first_tool_resp")" +tool_args="$(jq -r '.choices[0].message.tool_calls[0].function.arguments // "{}"' <<<"$first_tool_resp")" + +if [[ "$finish_reason" != "tool_calls" || "$tool_name" != "get_weather" ]]; then + echo "FAIL: expected tool call to get_weather" + echo "$first_tool_resp" | jq + exit 1 +fi + +mock_tool_result='{"city":"Berlin","temperature_c":8,"condition":"Cloudy"}' +second_tool_payload="$(jq -nc \ + --arg model "$MODEL_ID" \ + --arg args "$tool_args" \ + --arg toolContent "$mock_tool_result" \ + '{model:$model,messages:[ + {role:"user",content:"Use get_weather for Berlin."}, + {role:"assistant",content:null,tool_calls:[{id:"call_1",type:"function",function:{name:"get_weather",arguments:$args}}]}, + {role:"tool",name:"get_weather",content:$toolContent} + ]}')" + +second_tool_resp="$(curl -s "$BASE_URL/$API_VERSION/chat/completions" -H 'content-type: application/json' -d "$second_tool_payload")" +second_text="$(jq -r '.choices[0].message.content // empty' <<<"$second_tool_resp")" + +if [[ -z "$second_text" ]]; then + echo "FAIL: final response after tool result is empty" + echo "$second_tool_resp" | jq + exit 1 +fi + +echo "PASS: completion and tool-calling flow works" +echo "PASS info: completion_text=$completion_text" +echo "PASS info: tool_name=$tool_name tool_args=$tool_args" +echo "PASS assistant: $second_text" diff --git a/tests/test_function_call.sh b/tests/function_call.sh similarity index 100% rename from tests/test_function_call.sh rename to tests/function_call.sh diff --git a/tests/homebrew_feature_branch_install.sh b/tests/homebrew_feature_branch_install.sh new file mode 100755 index 0000000..98df943 --- /dev/null +++ b/tests/homebrew_feature_branch_install.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail +export HOMEBREW_NO_GITHUB_API=1 + +require() { + command -v "$1" >/dev/null 2>&1 || { + echo "FAIL: missing required command: $1" + exit 1 + } +} + +require brew +require git +require shasum +require curl +require swift + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +FORMULA_NAME="${FORMULA_NAME:-afm-api}" +TAP_NAME="${1:-tankibaj/localtap}" +KEEP_INSTALL="${KEEP_INSTALL:-0}" +KEEP_TAP="${KEEP_TAP:-0}" + +TAP_USER="${TAP_NAME%%/*}" +TAP_REPO="${TAP_NAME##*/}" +TAP_ROOT="$(brew --repository)/Library/Taps/${TAP_USER}/homebrew-${TAP_REPO}" +FORMULA_PATH="$TAP_ROOT/Formula/${FORMULA_NAME}.rb" + +SHORT_SHA="$(git -C "$REPO_ROOT" rev-parse --short HEAD)" +VERSION="0.0.0-feature.${SHORT_SHA}" +STAGE_DIR="${TMPDIR:-/tmp}/afm-api-feature-stage-${SHORT_SHA}" +TARBALL="${TMPDIR:-/tmp}/afm-api-feature-${SHORT_SHA}.tar.gz" +BACKUP_FORMULA="" +CREATED_TAP=0 + +cleanup() { + if [[ "$KEEP_INSTALL" != "1" ]]; then + brew uninstall "${TAP_NAME}/${FORMULA_NAME}" >/dev/null 2>&1 || true + fi + + if [[ -n "$BACKUP_FORMULA" && -f "$BACKUP_FORMULA" ]]; then + mv "$BACKUP_FORMULA" "$FORMULA_PATH" + elif [[ -f "$FORMULA_PATH" ]]; then + rm -f "$FORMULA_PATH" + fi + + if [[ "$CREATED_TAP" == "1" && "$KEEP_TAP" != "1" ]]; then + brew untap "$TAP_NAME" >/dev/null 2>&1 || true + fi + + rm -rf "$STAGE_DIR" + rm -f "$TARBALL" +} +trap cleanup EXIT + +if ! brew tap | rg -q "^${TAP_NAME}$"; then + brew tap-new "$TAP_NAME" >/dev/null + CREATED_TAP=1 +fi + +mkdir -p "$(dirname "$FORMULA_PATH")" +if [[ -f "$FORMULA_PATH" ]]; then + BACKUP_FORMULA="${FORMULA_PATH}.bak.$RANDOM" + cp "$FORMULA_PATH" "$BACKUP_FORMULA" +fi + +AFM_API_SOURCE_ROOT="$REPO_ROOT" "$REPO_ROOT/bin/afm-api" build >/dev/null + +rm -rf "$STAGE_DIR" +mkdir -p "$STAGE_DIR" +cp "$REPO_ROOT/bin/afm-api" "$STAGE_DIR/afm-api" +cp "$REPO_ROOT/.build/release/afm-api-server" "$STAGE_DIR/afm-api-server" +chmod +x "$STAGE_DIR/afm-api" "$STAGE_DIR/afm-api-server" +sed -i '' "s/__AFM_API_VERSION__/${VERSION}/g" "$STAGE_DIR/afm-api" + +tar -czf "$TARBALL" -C "$STAGE_DIR" afm-api afm-api-server +SHA="$(shasum -a 256 "$TARBALL" | awk '{print $1}')" + +cat > "$FORMULA_PATH" </dev/null 2>&1 || true +brew list --versions "${FORMULA_NAME}" >/dev/null 2>&1 || { + echo "FAIL: Homebrew formula install did not succeed for ${TAP_NAME}/${FORMULA_NAME}" + exit 1 +} + +afm-api --stop >/dev/null 2>&1 || true +afm-api --background >/dev/null +sleep 1 +curl -sf "http://127.0.0.1:8000/v1/health" >/dev/null +afm-api --stop >/dev/null 2>&1 || true + +echo "PASS: Homebrew feature-branch install test works" +echo "PASS info: tap=${TAP_NAME} version=${VERSION} sha=${SHA}" diff --git a/tests/perf_chat_and_tools.sh b/tests/perf_chat_and_tools.sh new file mode 100755 index 0000000..36b78f3 --- /dev/null +++ b/tests/perf_chat_and_tools.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +set -euo pipefail + +require() { + command -v "$1" >/dev/null 2>&1 || { + echo "FAIL: missing command: $1" + exit 1 + } +} + +require curl +require jq +require awk +require sort + +BASE_URL="${1:-http://127.0.0.1:8000}" +API_VERSION="${API_VERSION:-v1}" +WARMUP="${WARMUP:-5}" +COMPLETION_N="${COMPLETION_N:-20}" +TOOL_N="${TOOL_N:-20}" +AFM_API_REQUEST_LOGS="${AFM_API_REQUEST_LOGS:-0}" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +AFM_API_BIN="${AFM_API_BIN:-$REPO_ROOT/bin/afm-api}" + +extract_host() { + echo "$1" | sed -E 's#^https?://([^:/]+).*$#\1#' +} + +extract_port() { + local p + p="$(echo "$1" | sed -nE 's#^https?://[^:/]+:([0-9]+).*$#\1#p')" + if [[ -n "$p" ]]; then + echo "$p" + else + echo "8000" + fi +} + +PARSER_HOST="$(extract_host "$BASE_URL")" +PARSER_PORT="$(extract_port "$BASE_URL")" +RUNTIME_DIR="/tmp/afm-api-perf" +STARTED_SERVER=0 + +cleanup() { + if [[ "$STARTED_SERVER" == "1" ]]; then + AFM_API_RUNTIME_DIR="$RUNTIME_DIR" "$AFM_API_BIN" --stop >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +if ! curl -sf "$BASE_URL/$API_VERSION/health" >/dev/null 2>&1; then + if [[ ! -x "$AFM_API_BIN" ]]; then + echo "FAIL: server is not reachable at $BASE_URL and no local afm-api launcher found at $AFM_API_BIN" + exit 1 + fi + + AFM_API_RUNTIME_DIR="$RUNTIME_DIR" AFM_API_SOURCE_ROOT="$REPO_ROOT" AFM_API_REQUEST_LOGS="$AFM_API_REQUEST_LOGS" \ + "$AFM_API_BIN" --background --host "$PARSER_HOST" --port "$PARSER_PORT" >/dev/null + STARTED_SERVER=1 + + for _ in $(seq 1 120); do + if curl -sf "$BASE_URL/$API_VERSION/health" >/dev/null 2>&1; then + break + fi + sleep 0.1 + done +fi + +if ! curl -sf "$BASE_URL/$API_VERSION/health" >/dev/null 2>&1; then + echo "FAIL: server not reachable at $BASE_URL" + exit 1 +fi + +MODEL_ID="$(curl -s "$BASE_URL/$API_VERSION/models" | jq -r '.data[0].id // empty')" +if [[ -z "$MODEL_ID" ]]; then + echo "FAIL: could not resolve model id from $BASE_URL/$API_VERSION/models" + exit 1 +fi + +pct_ms() { + local file="$1" + local p="$2" + sort -n "$file" | awk -v p="$p" '{a[NR]=$1} END { if (NR==0) { print "0.00"; exit } idx=int((NR-1)*p)+1; printf "%.2f", a[idx]*1000 }' +} + +avg_ms() { + local file="$1" + awk '{s+=$1} END { if (NR==0) { print "0.00"; exit } printf "%.2f", (s/NR)*1000 }' "$file" +} + +run_measurement() { + local kind="$1" + local count="$2" + local warmup="$3" + local times_file="$4" + + : > "$times_file" + + local payload + if [[ "$kind" == "completion" ]]; then + payload="$(jq -nc --arg model "$MODEL_ID" '{model:$model,messages:[{role:"user",content:"Reply with exactly OK."}],temperature:0}')" + else + payload="$(jq -nc --arg model "$MODEL_ID" '{model:$model,messages:[{role:"user",content:"Use get_weather for Berlin."}],tools:[{type:"function",function:{name:"get_weather",description:"Get weather",parameters:{type:"object",properties:{city:{type:"string"}},required:["city"]}}}],tool_choice:{type:"function",function:{name:"get_weather"}}}')" + fi + + local total=$((count + warmup)) + local i + for i in $(seq 1 "$total"); do + local raw body t + raw="$(curl -s "$BASE_URL/$API_VERSION/chat/completions" -H 'content-type: application/json' -d "$payload" -w $'\n%{time_total}')" + body="${raw%$'\n'*}" + t="${raw##*$'\n'}" + + if [[ "$kind" == "completion" ]]; then + local txt + txt="$(jq -r '.choices[0].message.content // empty' <<<"$body")" + if [[ -z "$txt" ]]; then + echo "FAIL: completion response missing content" + echo "$body" | jq + exit 1 + fi + else + local finish tool + finish="$(jq -r '.choices[0].finish_reason // empty' <<<"$body")" + tool="$(jq -r '.choices[0].message.tool_calls[0].function.name // empty' <<<"$body")" + if [[ "$finish" != "tool_calls" || -z "$tool" ]]; then + echo "FAIL: tool-call response invalid" + echo "$body" | jq + exit 1 + fi + fi + + if [[ "$i" -gt "$warmup" ]]; then + echo "$t" >> "$times_file" + fi + done +} + +TMP_DIR="$(mktemp -d /tmp/afm-perf.XXXXXX)" +trap 'rm -rf "$TMP_DIR"; cleanup' EXIT + +COMPLETION_TIMES="$TMP_DIR/completion.times" +TOOL_TIMES="$TMP_DIR/tool.times" + +run_measurement completion "$COMPLETION_N" "$WARMUP" "$COMPLETION_TIMES" +run_measurement tool "$TOOL_N" "$WARMUP" "$TOOL_TIMES" + +c_avg="$(avg_ms "$COMPLETION_TIMES")" +c_p50="$(pct_ms "$COMPLETION_TIMES" 0.50)" +c_p95="$(pct_ms "$COMPLETION_TIMES" 0.95)" + +t_avg="$(avg_ms "$TOOL_TIMES")" +t_p50="$(pct_ms "$TOOL_TIMES" 0.50)" +t_p95="$(pct_ms "$TOOL_TIMES" 0.95)" + +echo "PASS: perf benchmark completed" +echo "PASS info: completion n=$COMPLETION_N warmup=$WARMUP avg_ms=$c_avg p50_ms=$c_p50 p95_ms=$c_p95" +echo "PASS info: tool_call n=$TOOL_N warmup=$WARMUP avg_ms=$t_avg p50_ms=$t_p50 p95_ms=$t_p95" diff --git a/tests/test_tool_country_info_restcountries.sh b/tests/tool_country_info_restcountries.sh similarity index 100% rename from tests/test_tool_country_info_restcountries.sh rename to tests/tool_country_info_restcountries.sh diff --git a/tests/test_tool_currency_frankfurter.sh b/tests/tool_currency_frankfurter.sh similarity index 100% rename from tests/test_tool_currency_frankfurter.sh rename to tests/tool_currency_frankfurter.sh diff --git a/tests/test_tool_public_holidays_nager.sh b/tests/tool_public_holidays_nager.sh similarity index 100% rename from tests/test_tool_public_holidays_nager.sh rename to tests/tool_public_holidays_nager.sh diff --git a/tests/test_tool_timezone_worldtimeapi.sh b/tests/tool_timezone_worldtimeapi.sh similarity index 100% rename from tests/test_tool_timezone_worldtimeapi.sh rename to tests/tool_timezone_worldtimeapi.sh diff --git a/tests/test_tool_weather_openmeteo.sh b/tests/tool_weather_openmeteo.sh similarity index 100% rename from tests/test_tool_weather_openmeteo.sh rename to tests/tool_weather_openmeteo.sh