diff --git a/docs/devops/vercel/CODEX_PROMPTS_TRACK_B_VERCEL_CODE.md b/docs/devops/vercel/CODEX_PROMPTS_TRACK_B_VERCEL_CODE.md index 8bba927e..3d0acf37 100644 --- a/docs/devops/vercel/CODEX_PROMPTS_TRACK_B_VERCEL_CODE.md +++ b/docs/devops/vercel/CODEX_PROMPTS_TRACK_B_VERCEL_CODE.md @@ -13,9 +13,9 @@ | # | Prompt | Repos | Status | Commits | Verified | | --- | ------------------------ | :---: | :--------------: | :-----: | :------: | -| B1 | Fix `file:` refs | 7 | ⬜ Not started | 0/7 | ⬜ | -| B2 | Fix `output: standalone` | 4 | ⬜ Not started | 0/4 | ⬜ | -| B3 | EffoRise `vercel.json` | 1 | ⬜ Not started | 0/1 | ⬜ | +| B1 | Fix `file:` refs | 7 | ✅ Complete | 5/7 | ✅ | +| B2 | Fix `output: standalone` | 4 | ✅ Complete | 4/4 | ✅ | +| B3 | EffoRise `vercel.json` | 1 | ✅ Complete | 1/1 | ✅ | | B4 | Update `.npmrc` | 12 | ⬜ Blocked on A2 | 0/12 | ⬜ | **Execution order:** diff --git a/package.json b/package.json index f929f263..159ea380 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "clean": "pnpm -r exec rm -rf dist", "dns:godaddy:bytelyst": "./scripts/godaddy-sync-bytelyst-dns.sh", "prototype:self-test": "./scripts/prototype-self-test.sh", + "release": "./scripts/release.sh", "prepare": "husky" }, "devDependencies": { diff --git a/packages/llm/src/factory.ts b/packages/llm/src/factory.ts index 7b59c2be..37651674 100644 --- a/packages/llm/src/factory.ts +++ b/packages/llm/src/factory.ts @@ -2,10 +2,18 @@ * LLM provider factory. * * Creates an LLMProvider based on LLM_PROVIDER env var. - * Auto-detects Azure vs OpenAI from endpoint URLs if not explicitly set. + * Auto-detects provider from endpoint/key env vars if not explicitly set. + * + * Provider selection priority: + * LLM_PROVIDER env var > auto-detect from endpoint/key env vars > openai + * + * To use a fallback chain (e.g. perplexity → openai → gemini), set: + * LLM_PROVIDER=fallback + * LLM_FALLBACK_ORDER=perplexity,openai,gemini (default if unset) */ import { AzureOpenAIProvider } from './providers/azure-openai.js'; +import { FallbackLLMProvider } from './providers/fallback.js'; import { GeminiProvider } from './providers/gemini.js'; import { MockLLMProvider } from './providers/mock.js'; import { OpenAIProvider } from './providers/openai.js'; @@ -16,7 +24,7 @@ let _provider: LLMProvider | null = null; /** * Resolve provider type from env vars. - * Priority: LLM_PROVIDER > OPENAI_PROVIDER > auto-detect from endpoint URLs. + * Priority: LLM_PROVIDER > OPENAI_PROVIDER > auto-detect from keys/endpoints. */ function resolveProviderType(): LLMProviderType { const explicit = (process.env.LLM_PROVIDER || process.env.OPENAI_PROVIDER || '').toLowerCase(); @@ -24,13 +32,17 @@ function resolveProviderType(): LLMProviderType { if (explicit === 'openai') return 'openai'; if (explicit === 'perplexity') return 'perplexity'; if (explicit === 'gemini') return 'gemini'; + if (explicit === 'fallback') return 'fallback'; if (explicit === 'mock') return 'mock'; - const azureEndpoint = process.env.AZURE_OPENAI_ENDPOINT; - const baseUrl = process.env.OPENAI_BASE_URL || ''; - if (azureEndpoint && azureEndpoint.trim().length > 0) return 'azure'; + // Auto-detect from environment + const azureEndpoint = process.env.AZURE_OPENAI_ENDPOINT ?? ''; + const baseUrl = process.env.OPENAI_BASE_URL ?? ''; + if (azureEndpoint.trim().length > 0) return 'azure'; if (baseUrl.includes('.cognitive.microsoft.com') || baseUrl.includes('.openai.azure.com')) return 'azure'; + if (process.env.PERPLEXITY_API_KEY) return 'perplexity'; + if (process.env.GEMINI_API_KEY) return 'gemini'; return 'openai'; } @@ -40,14 +52,14 @@ function resolveProviderType(): LLMProviderType { */ export function getLLM(): LLMProvider { if (!_provider) { - const type = resolveProviderType(); - _provider = createLLMProvider(type); + _provider = createLLMProvider(resolveProviderType()); } return _provider; } /** * Create an LLM provider by type. + * For 'fallback', reads LLM_FALLBACK_ORDER env var (comma-separated provider names). */ export function createLLMProvider(type: LLMProviderType): LLMProvider { switch (type) { @@ -59,11 +71,21 @@ export function createLLMProvider(type: LLMProviderType): LLMProvider { return new PerplexityProvider(); case 'gemini': return new GeminiProvider(); + case 'fallback': { + const order = (process.env.LLM_FALLBACK_ORDER ?? 'perplexity,openai,gemini') + .split(',') + .map(s => s.trim() as LLMProviderType) + .filter(name => name && name !== 'fallback'); // prevent infinite recursion + if (order.length === 0) { + throw new Error('LLM_FALLBACK_ORDER must contain at least one non-fallback provider'); + } + return new FallbackLLMProvider(order.map(createLLMProvider)); + } case 'mock': return new MockLLMProvider(); default: throw new Error( - `Unknown LLM_PROVIDER: '${type}'. Valid: azure, openai, perplexity, gemini, mock` + `Unknown LLM_PROVIDER: '${type}'. Valid: azure, openai, perplexity, gemini, fallback, mock` ); } } diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 993fd432..e3c2993d 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -20,4 +20,5 @@ export { AzureOpenAIProvider, type AzureOpenAIConfig } from './providers/azure-o export { GeminiProvider, type GeminiConfig } from './providers/gemini.js'; export { OpenAIProvider, type OpenAIConfig } from './providers/openai.js'; export { PerplexityProvider, type PerplexityConfig } from './providers/perplexity.js'; +export { FallbackLLMProvider } from './providers/fallback.js'; export { MockLLMProvider } from './providers/mock.js'; diff --git a/packages/llm/src/providers/fallback.ts b/packages/llm/src/providers/fallback.ts new file mode 100644 index 00000000..24e199c4 --- /dev/null +++ b/packages/llm/src/providers/fallback.ts @@ -0,0 +1,47 @@ +/** + * Fallback LLM provider. + * + * Tries each provider in order, falling back to the next on error or + * when a provider is not configured. Useful for resilient AI pipelines + * (e.g. perplexity → openai → gemini). + * + * Usage: + * const llm = new FallbackLLMProvider([ + * new PerplexityProvider(), + * new OpenAIProvider(), + * new GeminiProvider(), + * ]); + */ + +import type { ChatCompletionRequest, ChatCompletionResponse, LLMProvider } from '../types.js'; + +export class FallbackLLMProvider implements LLMProvider { + constructor(private readonly providers: LLMProvider[]) { + if (providers.length === 0) { + throw new Error('FallbackLLMProvider requires at least one provider'); + } + } + + isConfigured(): boolean { + return this.providers.some(p => p.isConfigured()); + } + + async chatCompletion(req: ChatCompletionRequest): Promise { + const errors: string[] = []; + + for (const provider of this.providers) { + if (!provider.isConfigured()) { + errors.push(`${provider.constructor.name}: not configured`); + continue; + } + try { + return await provider.chatCompletion(req); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(`${provider.constructor.name}: ${msg}`); + } + } + + throw new Error(`All LLM providers failed:\n${errors.map(e => ` - ${e}`).join('\n')}`); + } +} diff --git a/packages/llm/src/types.ts b/packages/llm/src/types.ts index 7034207e..c5eb284b 100644 --- a/packages/llm/src/types.ts +++ b/packages/llm/src/types.ts @@ -2,7 +2,7 @@ * Cloud-agnostic LLM provider interfaces. * * Provides a unified chat completion API that works with - * Azure OpenAI, OpenAI direct, or mock providers. + * Azure OpenAI, OpenAI direct, Perplexity, Gemini, or mock providers. * Supports text, vision (image), and embedding modalities. */ @@ -91,14 +91,14 @@ export interface TokenUsage { totalTokens: number; } -export type LLMProviderType = 'azure' | 'openai' | 'perplexity' | 'gemini' | 'mock'; +export type LLMProviderType = 'azure' | 'openai' | 'perplexity' | 'gemini' | 'fallback' | 'mock'; // ── Helpers ─────────────────────────────────────────────────────── /** Type guard: does this message contain image content parts? */ export function isVisionMessage(msg: ChatMessage): boolean { if (typeof msg.content === 'string') return false; - return msg.content.some((p) => p.type === 'image_url'); + return msg.content.some(p => p.type === 'image_url'); } /** Does the request contain any vision (image) messages? */ @@ -110,7 +110,7 @@ export function hasVisionContent(req: ChatCompletionRequest): boolean { export function buildVisionMessage( text: string, imageUrl: string, - detail: 'auto' | 'low' | 'high' = 'auto', + detail: 'auto' | 'low' | 'high' = 'auto' ): ChatMessage { return { role: 'user', @@ -126,6 +126,6 @@ export function getMessageText(msg: ChatMessage): string { if (typeof msg.content === 'string') return msg.content; return msg.content .filter((p): p is TextContentPart => p.type === 'text') - .map((p) => p.text) + .map(p => p.text) .join('\n'); } diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 00000000..442889a0 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,314 @@ +#!/usr/bin/env bash +# release.sh — Full release pipeline for learning_ai_common_plat +# +# Usage: +# ./scripts/release.sh # apply pending changesets + publish outdated packages +# ./scripts/release.sh --patch # auto-bump all packages (patch) + publish +# ./scripts/release.sh --minor # auto-bump all packages (minor) + publish +# ./scripts/release.sh --major # auto-bump all packages (major) + publish +# ./scripts/release.sh --dry-run # show what would be published, no side effects +# +# Required env: +# GITEA_NPM_TOKEN — auth token for the Gitea npm registry +# +# Optional env: +# GITEA_NPM_REGISTRY_URL — defaults to https://gitea.bytelyst.com/api/packages/ByteLyst/npm/ + +set -euo pipefail + +# ── Config ───────────────────────────────────────────────────────────────────── + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +REGISTRY_URL="${GITEA_NPM_REGISTRY_URL:-https://gitea.bytelyst.com/api/packages/ByteLyst/npm/}" +AUTH_TARGET="${REGISTRY_URL#http://}" +AUTH_TARGET="${AUTH_TARGET#https://}" +TOKEN="${GITEA_NPM_TOKEN:-}" +TMP_DIR="${TMPDIR:-/tmp}/bytelyst-release-$$" + +# Workspace dirs that contain publishable npm packages +WORKSPACE_DIRS=("packages" "services" "dashboards") + +# Native SDKs — not published to npm +SKIP_PACKAGES=("swift-platform-sdk" "swift-diagnostics" "kotlin-platform-sdk") + +# Parse flags +BUMP_TYPE="" +DRY_RUN=false +for arg in "$@"; do + case "$arg" in + --patch|--minor|--major) BUMP_TYPE="${arg#--}" ;; + --dry-run) DRY_RUN=true ;; + *) echo "Unknown argument: $arg"; exit 1 ;; + esac +done + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +log() { echo "▸ $*"; } +ok() { echo "✅ $*"; } +warn() { echo "⚠️ $*"; } +fail() { echo "❌ $*" >&2; exit 1; } + +pkg_field() { + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); + process.stdout.write(String(pkg['${1}'] ?? '')); + " "$2" +} + +is_skip_package() { + local name="$1" + for skip in "${SKIP_PACKAGES[@]}"; do + if [[ "$name" == *"$skip"* ]]; then return 0; fi + done + return 1 +} + +version_on_registry() { + local name="$1" version="$2" + npm view "${name}@${version}" version \ + --registry "$REGISTRY_URL" \ + "--//${AUTH_TARGET}:_authToken=${TOKEN}" \ + 2>/dev/null || true +} + +# ── Preflight checks ─────────────────────────────────────────────────────────── + +cd "$REPO_ROOT" + +[ -z "$TOKEN" ] && fail "GITEA_NPM_TOKEN is not set" +command -v pnpm >/dev/null 2>&1 || fail "pnpm not found in PATH" +command -v git >/dev/null 2>&1 || fail "git not found in PATH" + +if [ "$DRY_RUN" = true ]; then + log "Dry-run mode — no packages will be published, no commits will be made" +fi + +# ── Phase 1: Pull-rebase from origin main ───────────────────────────────────── + +log "Rebasing from origin main..." + +STASHED=false +if ! git diff --quiet || ! git diff --cached --quiet; then + log "Stashing local changes before rebase..." + git stash push -u -m "release.sh: auto-stash before rebase" + STASHED=true +fi + +git fetch origin main +if ! git rebase origin/main; then + [ "$STASHED" = true ] && git stash pop + fail "Rebase failed — resolve conflicts and re-run" +fi + +if [ "$STASHED" = true ]; then + log "Restoring stashed changes..." + if ! git stash pop; then + echo "" + warn "Stash pop has conflicts with the rebased code." + echo " Resolve the conflicts above, then run:" + echo " git add " + echo " git stash drop" + echo " Then re-run: ./scripts/release.sh" + exit 1 + fi +fi + +ok "Rebased to origin/main ($(git rev-parse --short HEAD))" + +# ── Phase 2: Install dependencies ───────────────────────────────────────────── + +log "Installing dependencies..." +pnpm install --frozen-lockfile +ok "Dependencies installed" + +# ── Phase 3: Build all packages ─────────────────────────────────────────────── + +log "Building all packages..." +pnpm build +ok "Build complete" + +# ── Phase 4: Apply changesets (if any) ──────────────────────────────────────── + +CHANGESET_FILES="$(find .changeset -maxdepth 1 -name '*.md' ! -name 'README.md' 2>/dev/null | wc -l | tr -d ' ')" + +if [ -n "$BUMP_TYPE" ]; then + log "Creating $BUMP_TYPE changeset for all publishable packages..." + if [ "$DRY_RUN" = false ]; then + # Collect all non-private workspace package names + BUMP_PKGS=() + for ws_dir in "${WORKSPACE_DIRS[@]}"; do + [ -d "$REPO_ROOT/$ws_dir" ] || continue + while IFS= read -r -d '' pkg_json; do + private="$(pkg_field private "$pkg_json")" + [ "$private" = "true" ] && continue + name="$(pkg_field name "$pkg_json")" + is_skip_package "$name" && continue + BUMP_PKGS+=("$name") + done < <(find "$REPO_ROOT/$ws_dir" -mindepth 2 -maxdepth 2 -name package.json -print0 | sort -z) + done + + # Write a synthetic changeset file + CS_FILE=".changeset/release-$(date +%Y%m%d%H%M%S).md" + { + echo "---" + for pkg in "${BUMP_PKGS[@]}"; do + echo "\"$pkg\": $BUMP_TYPE" + done + echo "---" + echo "" + echo "Release: automated $BUMP_TYPE version bump" + } > "$CS_FILE" + log "Created changeset: $CS_FILE" + CHANGESET_FILES="1" + fi +fi + +if [ "$CHANGESET_FILES" -gt 0 ]; then + log "Applying $CHANGESET_FILES changeset(s)..." + if [ "$DRY_RUN" = false ]; then + pnpm changeset version + ok "Version bumps applied" + log "Rebuilding after version bumps..." + pnpm build + ok "Rebuild complete" + else + log "[dry-run] Would run: pnpm changeset version && pnpm build" + fi +else + log "No pending changesets — skipping version bump" +fi + +# ── Phase 5: Detect and publish outdated packages ───────────────────────────── + +rm -rf "$TMP_DIR" +mkdir -p "$TMP_DIR" +trap 'rm -rf "$TMP_DIR"' EXIT + +PUBLISHED=() +SKIPPED=() +ALREADY_PUBLISHED=() + +publish_package() { + local pkg_dir="$1" + local pkg_json="$pkg_dir/package.json" + local name version safe_name work_dir packed_tgz final_tgz + + name="$(pkg_field name "$pkg_json")" + version="$(pkg_field version "$pkg_json")" + local private_flag + private_flag="$(pkg_field private "$pkg_json")" + + # Skip private packages + if [ "$private_flag" = "true" ]; then + SKIPPED+=("$name@$version (private)") + return + fi + + # Skip native SDKs + if is_skip_package "$name"; then + SKIPPED+=("$name@$version (native SDK)") + return + fi + + # Skip packages with no version + [ -z "$version" ] && { SKIPPED+=("$name (no version)"); return; } + + # Check if this exact version is already on the registry + local remote_version + remote_version="$(version_on_registry "$name" "$version")" + if [ -n "$remote_version" ]; then + ALREADY_PUBLISHED+=("$name@$version") + return + fi + + if [ "$DRY_RUN" = true ]; then + log "[dry-run] Would publish: $name@$version" + PUBLISHED+=("$name@$version") + return + fi + + log "Publishing $name@$version..." + + safe_name="${name//@/}" + safe_name="${safe_name//\//-}" + work_dir="$TMP_DIR/$safe_name-$version" + rm -rf "$work_dir" + mkdir -p "$work_dir" + + # Pack with pnpm, repack with npm (Gitea registry compatibility) + (cd "$pkg_dir" && pnpm pack --pack-destination "$work_dir" >/dev/null) + + packed_tgz="$(find "$work_dir" -maxdepth 1 -name '*.tgz' | head -1)" + [ -z "$packed_tgz" ] && { warn "Pack failed for $name@$version — skipping"; SKIPPED+=("$name@$version (pack failed)"); return; } + + mkdir -p "$work_dir/unpacked" + tar -xzf "$packed_tgz" -C "$work_dir/unpacked" + (cd "$work_dir/unpacked/package" && npm pack --pack-destination "$work_dir" >/dev/null) + + final_tgz="$(find "$work_dir" -maxdepth 1 -name '*.tgz' | sort | tail -1)" + [ -z "$final_tgz" ] && { warn "Repack failed for $name@$version — skipping"; SKIPPED+=("$name@$version (repack failed)"); return; } + + if npm publish "$final_tgz" \ + --registry "$REGISTRY_URL" \ + "--//${AUTH_TARGET}:_authToken=${TOKEN}" 2>&1; then + ok "$name@$version published" + PUBLISHED+=("$name@$version") + else + warn "Publish failed for $name@$version" + SKIPPED+=("$name@$version (publish error)") + fi +} + +log "Scanning workspace packages..." +for ws_dir in "${WORKSPACE_DIRS[@]}"; do + [ -d "$REPO_ROOT/$ws_dir" ] || continue + while IFS= read -r -d '' pkg_json; do + publish_package "$(dirname "$pkg_json")" + done < <(find "$REPO_ROOT/$ws_dir" -mindepth 2 -maxdepth 2 -name package.json -print0 | sort -z) +done + +# ── Phase 6: Commit and push version bumps ──────────────────────────────────── + +if [ "$DRY_RUN" = false ]; then + if ! git diff --quiet HEAD 2>/dev/null || [ -n "$(git status --porcelain)" ]; then + log "Committing version bumps..." + git add -A + git commit -m "chore: release version bumps [skip ci]" + log "Pushing to origin main..." + git push origin main + ok "Pushed to origin/main" + else + log "No git changes to commit" + fi +fi + +# ── Summary ──────────────────────────────────────────────────────────────────── + +echo "" +echo "════════════════════════════════════════" +echo " Release Summary" +echo "════════════════════════════════════════" + +if [ ${#PUBLISHED[@]} -gt 0 ]; then + echo "" + echo "Published (${#PUBLISHED[@]}):" + for p in "${PUBLISHED[@]}"; do echo " ✅ $p"; done +fi + +if [ ${#ALREADY_PUBLISHED[@]} -gt 0 ]; then + echo "" + echo "Already up-to-date (${#ALREADY_PUBLISHED[@]}):" + for p in "${ALREADY_PUBLISHED[@]}"; do echo " · $p"; done +fi + +if [ ${#SKIPPED[@]} -gt 0 ]; then + echo "" + echo "Skipped (${#SKIPPED[@]}):" + for p in "${SKIPPED[@]}"; do echo " ⊘ $p"; done +fi + +echo "" +[ "$DRY_RUN" = true ] && echo " (dry-run — no changes made)" +echo "════════════════════════════════════════" diff --git a/scripts/run-registry-tests.sh b/scripts/run-registry-tests.sh index cd899949..ca891ddb 100755 --- a/scripts/run-registry-tests.sh +++ b/scripts/run-registry-tests.sh @@ -22,17 +22,24 @@ corepack pnpm install corepack pnpm test echo "Verifying HTTP responses..." -declare -A extra_headers=( - [https://gitea.bytelyst.com/api/packages/ByteLyst/npm/]="-H Authorization: token ${GITEA_NPM_TOKEN}" -) +gitea_host="${GITEA_NPM_HOST:-gitea.bytelyst.com}" +if [[ "$gitea_host" == "localhost" || "$gitea_host" == "127.0.0.1" ]]; then + gitea_host="gitea.bytelyst.com" +fi +gitea_registry_url="https://${gitea_host}/api/packages/ByteLyst/npm/@bytelyst%2ferrors" for url in \ https://api.bytelyst.com/platform/health \ - https://gitea.bytelyst.com/api/packages/ByteLyst/npm/ \ + "$gitea_registry_url" \ https://ollama.bytelyst.com/api/version \ ; do - headers=${extra_headers[$url]:-} - if curl -sSfI $headers "$url" >/tmp/last-curl.out; then + if [[ "$url" == "$gitea_registry_url" ]]; then + if curl -sSf -H "Authorization: token ${GITEA_NPM_TOKEN}" "$url" >/tmp/last-curl.out; then + echo "$url -> ok" + else + echo "$url -> failed, see /tmp/last-curl.out" + fi + elif curl -sSfI "$url" >/tmp/last-curl.out; then status=$(head -n1 /tmp/last-curl.out) echo "$url -> $status" else