Resolve security audit findings
ober
0c7e3927eb31ff4e148337fab613a92d46390d3b
--- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,9 +13,9 @@ jobs: verify-private: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" --- a/Makefile +++ b/Makefile @@ -5,8 +5,9 @@ EVIDENCE_DIR ?= dist/release-evidence PROVENANCE_DIR ?= dist/provenance SBOM_DIR ?= dist/sbom REPRO_DIR ?= dist/reproducibility +TARGET_EVIDENCE_DIR ?= dist/target-evidence -.PHONY: security test provenance-evidence sbom reproducibility-report verify release-evidence clean +.PHONY: security test provenance-evidence sbom reproducibility-report target-evidence verify release-evidence clean security: sh scripts/security-check.sh @@ -14,6 +15,7 @@ security: test: files="$$(git ls-files --cached --others --exclude-standard '*.py')"; \ if [ -n "$$files" ]; then $(PYTHON) -m py_compile $$files; fi + $(PYTHON) -m unittest discover -s tests -p 'test_*.py' -v provenance-evidence: JLORA_PROVENANCE_DIR="$(PROVENANCE_DIR)" \ @@ -30,17 +32,21 @@ reproducibility-report: JLORA_REPRO_DIR="$(REPRO_DIR)" \ sh scripts/reproducibility-report.sh -verify: security test provenance-evidence sbom reproducibility-report +target-evidence: + JLORA_TARGET_EVIDENCE_DIR="$(TARGET_EVIDENCE_DIR)" sh scripts/target-evidence.sh + +verify: security test provenance-evidence sbom reproducibility-report target-evidence grep -q '^status=match$$' "$(REPRO_DIR)/report.txt" release-evidence: - rm -rf $(EVIDENCE_DIR) $(PROVENANCE_DIR) $(SBOM_DIR) $(REPRO_DIR) + rm -rf $(EVIDENCE_DIR) $(PROVENANCE_DIR) $(SBOM_DIR) $(REPRO_DIR) $(TARGET_EVIDENCE_DIR) mkdir -p $(EVIDENCE_DIR) $(MAKE) security >$(EVIDENCE_DIR)/security.log 2>&1 $(MAKE) test >$(EVIDENCE_DIR)/python-syntax.log 2>&1 $(MAKE) provenance-evidence >$(EVIDENCE_DIR)/provenance-evidence.log 2>&1 $(MAKE) sbom >$(EVIDENCE_DIR)/sbom.log 2>&1 $(MAKE) reproducibility-report >$(EVIDENCE_DIR)/reproducibility.log 2>&1 + $(MAKE) target-evidence >$(EVIDENCE_DIR)/target-evidence.log 2>&1 grep -q '^status=match$$' "$(REPRO_DIR)/report.txt" git rev-parse HEAD >$(EVIDENCE_DIR)/git-commit.txt git status --short --ignored >$(EVIDENCE_DIR)/git-status-ignored.txt @@ -48,13 +54,15 @@ release-evidence: git ls-files >$(EVIDENCE_DIR)/tracked-files.txt git ls-files --cached --others --exclude-standard >$(EVIDENCE_DIR)/working-files.txt git status --ignored --short | sed -n '/^!!/p' >$(EVIDENCE_DIR)/ignored-artifacts.txt - git ls-files --cached --others --exclude-standard | grep -E '(\.py|\.sh|\.yaml|\.toml|\.json|\.md|Modelfile|LICENSE|SECURITY.md)$$' | xargs shasum -a 256 >$(EVIDENCE_DIR)/source-sha256.txt + git ls-files --cached --others --exclude-standard | grep -E '(\.py|\.sh|\.yaml|\.toml|\.json|\.tsv|\.md|Modelfile|LICENSE|SECURITY.md)$$' | xargs shasum -a 256 >$(EVIDENCE_DIR)/source-sha256.txt rm -rf $(EVIDENCE_DIR)/provenance cp -R $(PROVENANCE_DIR) $(EVIDENCE_DIR)/provenance rm -rf $(EVIDENCE_DIR)/sbom cp -R $(SBOM_DIR) $(EVIDENCE_DIR)/sbom rm -rf $(EVIDENCE_DIR)/reproducibility cp -R $(REPRO_DIR) $(EVIDENCE_DIR)/reproducibility + rm -rf $(EVIDENCE_DIR)/target-evidence + cp -R $(TARGET_EVIDENCE_DIR) $(EVIDENCE_DIR)/target-evidence { echo "repo=jerboa-lora"; echo "platform=$$(uname -srm)"; $(PYTHON) --version; } >$(EVIDENCE_DIR)/build-env.txt sh scripts/sanitize-evidence.sh "$(EVIDENCE_DIR)" --- a/SECURITY.md +++ b/SECURITY.md @@ -34,6 +34,14 @@ does not authorize public release. Public release remains blocked until dataset licenses, base-model licenses, generated outputs, private account references, and history secrets are reviewed. +The supported v6 path reads `security/training-lock.json`, uses a digest-pinned +OCI image and exact base-model commit, verifies all 47 model files against +`security/model-files.lock.tsv`, and refuses remote model code. Fetch credentials +are removed before training; training runs offline in a new network namespace. +If the target cannot create that namespace, training fails closed. Legacy +RunPod/vLLM launchers that exposed unauthenticated inference or logs are retired +stubs, and local MLX launchers permit loopback binds only. + `make provenance-evidence` records private model/data cards, source/data/config hashes, local model artifact inventory, and split-review proof status. Required split proof fails closed unless it includes reviewed base-model, dataset, model @@ -48,6 +56,10 @@ card, dataset card, source split, and weight-publication markers. license clearance. - Do not publish model outputs from this repo without a model card, dataset card, safety/eval report, and redistribution decision. +- API credentials belong in owner-only files or environment variables. Never + place them in URLs or argv, and never print generated credential-bearing + configuration. `configure_opencode.py` writes atomically with mode 0600 and + prints only a redacted status line. ## Reporting --- a/axolotl_jerboa_v6_cpt.yaml +++ b/axolotl_jerboa_v6_cpt.yaml @@ -3,9 +3,10 @@ # Invariant: this file trains a PEFT adapter only. Do not add merge settings. base_model: __V6_BASE__ +revision: __V6_REVISION__ model_type: AutoModelForCausalLM tokenizer_type: AutoTokenizer -trust_remote_code: true +trust_remote_code: false load_in_4bit: true adapter: qlora --- a/axolotl_jerboa_v6_dpo.yaml +++ b/axolotl_jerboa_v6_dpo.yaml @@ -4,9 +4,10 @@ # adapter. Do not fuse this adapter into the base for the v6 artifact. base_model: __V6_BASE__ +revision: __V6_REVISION__ model_type: AutoModelForCausalLM tokenizer_type: AutoTokenizer -trust_remote_code: true +trust_remote_code: false load_in_4bit: true adapter: qlora --- a/axolotl_jerboa_v6_sft.yaml +++ b/axolotl_jerboa_v6_sft.yaml @@ -4,9 +4,10 @@ # The release artifact remains an adapter directory, not fused weights. base_model: __V6_BASE__ +revision: __V6_REVISION__ model_type: AutoModelForCausalLM tokenizer_type: AutoTokenizer -trust_remote_code: true +trust_remote_code: false load_in_4bit: true adapter: qlora --- a/build_ollama_gguf.sh +++ b/build_ollama_gguf.sh @@ -1,198 +1,5 @@ -#!/bin/bash -# Convert the merged Jerboa Qwen3-Coder-30B-A3B model to GGUF and register -# it as Ollama models, ready for `ollama push`. -# -# Two output tags: -# jerboa-qwen:q8_0 — ~32GB, indistinguishable from F16 (the upload tag, -# also aliased as "latest" so it's what users get by -# default). -# jerboa-qwen:q4_k_m — ~17GB, smaller; some loss of fine-tune signal but -# runs comfortably on smaller machines. -# -# Disk usage during build (peak): ~115GB. Final (after cleanup): ~50GB. -# Override with: -# KEEP_F16=1 keep the intermediate F16 GGUF (~60GB) -# QUANTS="q8_0" build only specific quants (space-separated) -# CTX=131072 Modelfile num_ctx (default 32768) -# -# Usage: -# ./build_ollama_gguf.sh +#!/bin/sh +set -eu -set -euo pipefail -cd "$(dirname "$0")" - -SRC="${SRC:-./runpod-pipeline-final}" -LLAMA_CPP="${LLAMA_CPP:-./llama.cpp}" -OUT_DIR="${OUT_DIR:-./gguf}" -F16_GGUF="$OUT_DIR/jerboa-qwen3.f16.gguf" -MODEL_NAME="${MODEL_NAME:-jerboa-qwen}" -QUANTS="${QUANTS:-q8_0 q4_k_m}" -CTX="${CTX:-32768}" -KEEP_F16="${KEEP_F16:-0}" - -echo "=== Build Jerboa Ollama GGUFs ===" -echo " Source: $SRC" -echo " Output: $OUT_DIR" -echo " Quants: $QUANTS" -echo " Model: $MODEL_NAME" -echo " Context: $CTX" -echo "" - -# ── Sanity ────────────────────────────────────────────────────────────────── -if [ ! -d "$SRC" ]; then - echo "ERROR: source model dir '$SRC' not found." >&2 - exit 1 -fi -if ! command -v ollama &>/dev/null; then - echo "ERROR: 'ollama' not found. Install from https://ollama.com" >&2 - exit 1 -fi -mkdir -p "$OUT_DIR" - -# ── Step 1: llama.cpp (clone + build with Metal) ──────────────────────────── -echo "=== Step 1: llama.cpp ===" -if [ ! -d "$LLAMA_CPP/.git" ]; then - echo "Cloning llama.cpp (full, need build) ..." - git clone --depth 1 https://github.com/ggml-org/llama.cpp.git "$LLAMA_CPP" -fi - -# Verify Qwen3 MoE support -if ! grep -q -i 'qwen3.*moe\|qwen3moe' "$LLAMA_CPP/convert_hf_to_gguf.py"; then - echo "WARNING: this llama.cpp checkout may not support Qwen3 MoE." >&2 - echo "Consider 'cd $LLAMA_CPP && git pull' to refresh." >&2 -fi - -if [ ! -x "$LLAMA_CPP/build/bin/llama-quantize" ]; then - echo "Building llama.cpp (Metal) ..." - cmake -S "$LLAMA_CPP" -B "$LLAMA_CPP/build" -DGGML_METAL=ON -DLLAMA_CURL=OFF >/dev/null - cmake --build "$LLAMA_CPP/build" --config Release --target llama-quantize -j -fi -QUANTIZE="$LLAMA_CPP/build/bin/llama-quantize" - -# Python deps for convert_hf_to_gguf.py -if ! python3 -c "import gguf, transformers, torch, safetensors" 2>/dev/null; then - echo "Installing Python deps for HF→GGUF converter ..." - python3 -m pip install --break-system-packages gguf transformers safetensors \ - torch --index-url https://download.pytorch.org/whl/cpu || \ - python3 -m pip install --break-system-packages gguf transformers safetensors torch -fi - -# ── Step 2: HF merged → F16 GGUF ──────────────────────────────────────────── -echo "" -echo "=== Step 2: HF → F16 GGUF (intermediate, ~60GB) ===" -if [ -f "$F16_GGUF" ]; then - echo "Already exists: $F16_GGUF" -else - python3 "$LLAMA_CPP/convert_hf_to_gguf.py" \ - "$SRC" \ - --outfile "$F16_GGUF" \ - --outtype f16 - echo "Wrote $F16_GGUF ($(du -h "$F16_GGUF" | cut -f1))" -fi - -# ── Step 3: F16 → quantized variants ──────────────────────────────────────── -echo "" -echo "=== Step 3: Quantize ===" -gguf_path_for() { echo "$OUT_DIR/jerboa-qwen3.$1.gguf"; } -for q in $QUANTS; do - Q_UPPER=$(echo "$q" | tr 'a-z' 'A-Z') - OUT="$(gguf_path_for "$q")" - if [ -f "$OUT" ]; then - echo " $q: already exists ($(du -h "$OUT" | cut -f1))" - else - echo " $q: quantizing ..." - "$QUANTIZE" "$F16_GGUF" "$OUT" "$Q_UPPER" - echo " $q: wrote $OUT ($(du -h "$OUT" | cut -f1))" - fi -done - -# ── Step 4: Modelfiles + ollama create ────────────────────────────────────── -echo "" -echo "=== Step 4: Register Ollama models ===" - -SYSTEM_PROMPT='You are an expert in Jerboa Scheme, a Chez-Scheme-based dialect with a Gerbil-flavored prelude. You provide accurate, idiomatic Jerboa code with correct imports, function names, and arities. Module paths use the (jerboa ...) and (std ...) forms — never :std/foo (Gerbil) or (srfi :NN) (R7). You know the prelude, the actor system, fibers, the FFI, capability security, the macro system (defrules, syntax-case), pattern matching (match), and how Jerboa diverges from Gerbil/Racket/Clojure/SRFI. When writing code, always include required (import ...) statements.' - -for q in $QUANTS; do - GGUF="$(gguf_path_for "$q")" - MF="$OUT_DIR/Modelfile.$q" - cat > "$MF" <<EOF -FROM $(cd "$(dirname "$GGUF")" && pwd)/$(basename "$GGUF") - -SYSTEM "$SYSTEM_PROMPT" - -TEMPLATE """{{- if or .System .Tools }}<|im_start|>system -{{- if .System }} -{{ .System }} -{{- end }} -{{- if .Tools }} - -# Tools - -You may call one or more functions to assist with the user query. - -You are provided with function signatures within <tools></tools> XML tags: -<tools> -{{- range .Tools }} -{"type": "function", "function": {{ .Function }}} -{{- end }} -</tools> - -For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags: -<tool_call> -{"name": <function-name>, "arguments": <args-json-object>} -</tool_call> -{{- end }}<|im_end|> -{{ end }} -{{- range \$i, \$_ := .Messages }} -{{- \$last := eq (len (slice \$.Messages \$i)) 1 -}} -{{- if eq .Role "user" }}<|im_start|>user -{{ .Content }}<|im_end|> -{{ else if eq .Role "assistant" }}<|im_start|>assistant -{{ if .Content }}{{ .Content }} -{{- else if .ToolCalls }}<tool_call> -{{ range .ToolCalls }}{"name": "{{ .Function.Name }}", "arguments": {{ .Function.Arguments }}} -{{ end }}</tool_call> -{{- end }}{{ if not \$last }}<|im_end|> -{{ end }} -{{- else if eq .Role "tool" }}<|im_start|>user -<tool_response> -{{ .Content }} -</tool_response><|im_end|> -{{ end }} -{{- if and (ne .Role "assistant") \$last }}<|im_start|>assistant -{{ end }} -{{- end }}""" - -PARAMETER temperature 0.2 -PARAMETER num_ctx $CTX -PARAMETER stop "<|im_start|>" -PARAMETER stop "<|im_end|>" -EOF - TAG="$MODEL_NAME:$q" - echo " Creating $TAG ..." - ollama create "$TAG" -f "$MF" -done - -# Alias the q8_0 build as "latest" so plain `ollama pull jerboa-qwen` gets the -# highest-fidelity tag we publish. -if echo " $QUANTS " | grep -q ' q8_0 '; then - echo " Tagging $MODEL_NAME:latest -> $MODEL_NAME:q8_0" - ollama cp "$MODEL_NAME:q8_0" "$MODEL_NAME:latest" -fi - -# ── Step 5: Cleanup F16 intermediate ──────────────────────────────────────── -echo "" -if [ "$KEEP_F16" = "1" ]; then - echo "=== Keeping F16 intermediate (KEEP_F16=1): $F16_GGUF ===" -else - echo "=== Step 5: Removing F16 intermediate to reclaim ~60GB ===" - rm -f "$F16_GGUF" -fi - -echo "" -echo "=== Done ===" -ollama list | grep -E "^$MODEL_NAME" -echo "" -echo "Push to ollama.com:" -echo " ./push_ollama.sh jaimef # pushes 'latest' (= q8_0)" -echo " ollama push jaimef/jerboa-qwen:q4_k_m # smaller variant" +echo "build_ollama_gguf.sh is retired until conversion dependencies and model artifacts have a reviewed lock." >&2 +exit 1 new file mode 100755 --- /dev/null +++ b/configure_opencode.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Atomically configure OpenCode without printing provider credentials.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import stat +import tempfile +from pathlib import Path +from typing import Any + + +ENDPOINT = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{2,127}\Z") + + +def fail(message: str) -> None: + raise SystemExit(f"configure-opencode: {message}") + + +def secure_directory(path: Path) -> None: + path.mkdir(mode=0o700, parents=True, exist_ok=True) + metadata = os.stat(path, follow_symlinks=False) + if path.is_symlink() or not stat.S_ISDIR(metadata.st_mode): + fail(f"configuration directory is not a real directory: {path}") + if metadata.st_uid != os.getuid(): + fail(f"configuration directory is not owned by this user: {path}") + os.chmod(path, 0o700) + + +def load_existing(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + metadata = os.stat(path, follow_symlinks=False) + if path.is_symlink() or not stat.S_ISREG(metadata.st_mode): + fail(f"refusing non-regular configuration file: {path}") + if metadata.st_uid != os.getuid(): + fail(f"configuration file is not owned by this user: {path}") + if metadata.st_size > 4 * 1024 * 1024: + fail("existing configuration exceeds 4 MiB") + try: + data = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as exc: + fail(f"cannot parse existing configuration: {exc}") + if not isinstance(data, dict): + fail("existing configuration must be a JSON object") + return data + + +def atomic_private_json(path: Path, value: dict[str, Any]) -> None: + secure_directory(path.parent) + payload = (json.dumps(value, indent=2, sort_keys=True) + "\n").encode() + fd, temporary = tempfile.mkstemp(prefix=".opencode-", suffix=".tmp", dir=path.parent) + temp_path = Path(temporary) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "wb", closefd=True) as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + metadata = os.stat(temp_path, follow_symlinks=False) + if temp_path.is_symlink() or metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) != 0o600: + fail("temporary configuration ownership or mode check failed") + if path.exists() and path.is_symlink(): + fail("refusing to replace a configuration symlink") + os.replace(temp_path, path) + os.chmod(path, 0o600) + directory_fd = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: + try: + temp_path.unlink() + except FileNotFoundError: + pass + + +def local_provider(kind: str, port: int) -> dict[str, Any]: + if kind == "ollama": + return { + "ollama": { + "npm": "@ai-sdk/openai-compatible", + "name": "Ollama (local)", + "options": {"baseURL": "http://127.0.0.1:11434/v1"}, + "models": {"jerboa-qwen": {"name": "Jerboa Qwen"}}, + } + } + return { + "mlx": { + "npm": "@ai-sdk/openai-compatible", + "name": "MLX (local)", + "options": {"baseURL": f"http://127.0.0.1:{port}/v1"}, + "models": {"jerboa-local": {"name": "Jerboa local MLX"}}, + } + } + + +def runpod_provider(endpoint: str, key: str) -> dict[str, Any]: + if not ENDPOINT.fullmatch(endpoint): + fail("endpoint id contains invalid characters") + if not (20 <= len(key) <= 8192) or any(ord(ch) < 33 or ord(ch) == 127 for ch in key): + fail("RUNPOD_API_KEY has an invalid length or characters") + return { + "runpod": { + "npm": "@ai-sdk/openai-compatible", + "name": "RunPod (authenticated serverless)", + "options": { + "baseURL": f"https://api.runpod.ai/v2/{endpoint}/openai/v1", + "apiKey": key, + }, + "models": {"jerboa-qwen": {"name": "Jerboa Qwen"}}, + } + } + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + parser.add_argument("mode", choices=("ollama", "mlx", "runpod", "both")) + parser.add_argument("value", nargs="?") + parser.add_argument("--config", type=Path) + return parser + + +def main() -> None: + args = build_parser().parse_args() + config_path = args.config or Path.home() / ".config" / "opencode" / "opencode.json" + existing = load_existing(config_path) + providers: dict[str, Any] + if args.mode == "ollama": + providers = local_provider("ollama", 11434) + elif args.mode == "mlx": + try: + port = int(args.value or "8080") + except ValueError: + fail("MLX port must be numeric") + if not 1024 <= port <= 65535: + fail("MLX port must be between 1024 and 65535") + providers = local_provider("mlx", port) + else: + if not args.value: + fail(f"{args.mode} requires an endpoint id") + key = os.environ.get("RUNPOD_API_KEY", "") + if not key: + fail("RUNPOD_API_KEY is required") + providers = runpod_provider(args.value, key) + if args.mode == "both": + providers = {**local_provider("ollama", 11434), **providers} + config: dict[str, Any] = { + "$schema": "https://opencode.ai/config.json", + "provider": providers, + } + if isinstance(existing.get("mcp"), dict): + config["mcp"] = existing["mcp"] + atomic_private_json(config_path, config) + print(f"Wrote private OpenCode configuration to {config_path} (credentials redacted).") + + +if __name__ == "__main__": + main() --- a/configure_opencode.sh +++ b/configure_opencode.sh @@ -1,286 +1,5 @@ -#!/bin/bash -# Configure OpenCode to use the Jerboa Qwen model. -# -# Commands: -# ollama Configure for local Ollama -# mlx [PORT] Configure for local mlx_lm server (default port 8080) -# runpod <ENDPOINT_ID> Configure for RunPod endpoint -# both <ENDPOINT_ID> Configure both Ollama and RunPod -# -# Writes to ~/.config/opencode/opencode.json, preserving existing MCP config. -# -# Requires: RUNPOD_API_KEY env var (for runpod/both modes) -# -# Usage: -# ./configure_opencode.sh ollama -# ./configure_opencode.sh mlx -# ./configure_opencode.sh mlx 9090 -# ./configure_opencode.sh runpod abc123 -# ./configure_opencode.sh both abc123 +#!/bin/sh +set -eu -set -euo pipefail - -CONFIG_DIR="$HOME/.config/opencode" -CONFIG_FILE="$CONFIG_DIR/opencode.json" - -cmd_help() { - echo "Usage: $0 <mode> [args]" - echo "" - echo "Modes:" - echo " ollama Configure for local Ollama" - echo " mlx [PORT] Configure for local mlx_lm server (default 8080)" - echo " runpod <ENDPOINT_ID> Configure for RunPod serverless" - echo " both <ENDPOINT_ID> Configure both providers" - echo "" - echo "Writes to: $CONFIG_FILE" -} - -write_config() { - local providers_json="$1" - local existing_mcp="{}" - - if [ -f "$CONFIG_FILE" ]; then - existing_mcp=$(python3 -c " -import json, sys -try: - with open('$CONFIG_FILE') as f: - data = json.load(f) - mcp = data.get('mcp', {}) - if mcp: - print(json.dumps(mcp)) - else: - print('{}') -except: - print('{}') -" 2>/dev/null || echo "{}") - fi - - mkdir -p "$CONFIG_DIR" - - python3 -c " -import json - -providers = json.loads('''$providers_json''') -mcp = json.loads('''$existing_mcp''') - -config = { - '\$schema': 'https://opencode.ai/config.json', - 'provider': providers -} - -if mcp and mcp != {}: - config['mcp'] = mcp - -with open('$CONFIG_FILE', 'w') as f: - json.dump(config, f, indent=2) - f.write('\n') - -print(json.dumps(config, indent=2)) -" -} - -ollama_provider() { - cat <<'EOF' -{ - "ollama": { - "npm": "@ai-sdk/openai-compatible", - "name": "Ollama (local)", - "options": { - "baseURL": "http://localhost:11434/v1" - }, - "models": { - "jerboa-qwen": { - "name": "Jerboa Qwen" - } - } - } -} -EOF -} - -mlx_provider() { - local port="$1" - local model_path="$(cd "$(dirname "$0")" && pwd)/jerboa-mlx-4bit-v2" - cat <<EOF -{ - "mlx": { - "npm": "@ai-sdk/openai-compatible", - "name": "MLX (local)", - "options": { - "baseURL": "http://127.0.0.1:${port}/v1" - }, - "models": { - "${model_path}": { - "name": "Jerboa Qwen3 30B (MLX 4-bit)" - } - } - } -} -EOF -} - -runpod_provider() { - local endpoint_id="$1" - local api_key="$2" - cat <<EOF -{ - "runpod": { - "npm": "@ai-sdk/openai-compatible", - "name": "RunPod (serverless)", - "options": { - "baseURL": "https://api.runpod.ai/v2/${endpoint_id}/openai/v1", - "apiKey": "${api_key}" - }, - "models": { - "jerboa-qwen": { - "name": "Jerboa Qwen 7B" - } - } - } -} -EOF -} - -pod_provider() { - # RunPod *pod* (not serverless): reach vLLM via the Cloudflare HTTP proxy. - # The proxy blocks non-browser User-Agents, so send one explicitly. - local pod_id="$1" - cat <<EOF -{ - "runpod-pod": { - "npm": "@ai-sdk/openai-compatible", - "name": "Jerboa v5 (RunPod pod)", - "options": { - "baseURL": "https://${pod_id}-8000.proxy.runpod.net/v1", - "headers": { - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36" - } - }, - "models": { - "jerboa-v5": { - "name": "Jerboa Qwen3.6-35B-A3B v5 (BF16)" - } - } - } -} -EOF -} - -both_providers() { - local endpoint_id="$1" - local api_key="$2" - cat <<EOF -{ - "ollama": { - "npm": "@ai-sdk/openai-compatible", - "name": "Ollama (local)", - "options": { - "baseURL": "http://localhost:11434/v1" - }, - "models": { - "jerboa-qwen": { - "name": "Jerboa Qwen" - } - } - }, - "runpod": { - "npm": "@ai-sdk/openai-compatible", - "name": "RunPod (serverless)", - "options": { - "baseURL": "https://api.runpod.ai/v2/${endpoint_id}/openai/v1", - "apiKey": "${api_key}" - }, - "models": { - "jerboa-qwen": { - "name": "Jerboa Qwen 7B" - } - } - } -} -EOF -} - -case "${1:-help}" in - ollama) - echo "=== Configuring OpenCode for local Ollama ===" - echo "" - providers=$(ollama_provider) - write_config "$providers" - echo "" - echo "Written to: $CONFIG_FILE" - echo "" - echo "Make sure Ollama is running: ollama serve" - ;; - mlx) - port="${2:-8080}" - echo "=== Configuring OpenCode for local MLX server (port $port) ===" - echo "" - providers=$(mlx_provider "$port") - write_config "$providers" - echo "" - echo "Written to: $CONFIG_FILE" - echo "" - echo "Start the MLX server in another terminal:" - echo " cd $(pwd) && .venv/bin/mlx_lm server --model jerboa-mlx-4bit-v2 --port $port" - ;; - runpod) - endpoint_id="${2:-}" - if [ -z "$endpoint_id" ]; then - echo "Usage: $0 runpod <ENDPOINT_ID>" - exit 1 - fi - if [ -z "${RUNPOD_API_KEY:-}" ]; then - echo "ERROR: RUNPOD_API_KEY not set." - exit 1 - fi - echo "=== Configuring OpenCode for RunPod ===" - echo "" - providers=$(runpod_provider "$endpoint_id" "$RUNPOD_API_KEY") - write_config "$providers" - echo "" - echo "Written to: $CONFIG_FILE" - ;; - pod) - pod_id="${2:-}" - if [ -z "$pod_id" ]; then - echo "Usage: $0 pod <POD_ID> (e.g. y4vysb5bfzdxiw)" - exit 1 - fi - echo "=== Configuring OpenCode for RunPod pod $pod_id (vLLM via proxy) ===" - echo "" - providers=$(pod_provider "$pod_id") - write_config "$providers" - echo "" - echo "Written to: $CONFIG_FILE" - echo "" - echo "Endpoint: https://${pod_id}-8000.proxy.runpod.net/v1 (model: jerboa-v5)" - echo "NOTE: the model is grounded by the system prompt — ensure the agent" - echo " sends a Jerboa system message, or answers may drift." - ;; - both) - endpoint_id="${2:-}" - if [ -z "$endpoint_id" ]; then - echo "Usage: $0 both <ENDPOINT_ID>" - exit 1 - fi - if [ -z "${RUNPOD_API_KEY:-}" ]; then - echo "ERROR: RUNPOD_API_KEY not set." - exit 1 - fi - echo "=== Configuring OpenCode for Ollama + RunPod ===" - echo "" - providers=$(both_providers "$endpoint_id" "$RUNPOD_API_KEY") - write_config "$providers" - echo "" - echo "Written to: $CONFIG_FILE" - ;; - help|--help|-h) - cmd_help - ;; - *) - echo "Unknown mode: $1" - echo "" - cmd_help - exit 1 - ;; -esac +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec python3 "$SCRIPT_DIR/configure_opencode.py" "$@" --- a/convert_to_mlx.sh +++ b/convert_to_mlx.sh @@ -14,8 +14,8 @@ if [ ! -d "$SRC" ]; then fi if ! python3 -c "import mlx_lm" 2>/dev/null; then - echo "Installing mlx-lm..." - pip install --quiet mlx-lm + echo "error: mlx_lm is not present in the reviewed environment; runtime installs are forbidden." >&2 + exit 1 fi echo "Converting $SRC -> $DST (4-bit MLX)..." --- a/deploy_runpod.sh +++ b/deploy_runpod.sh @@ -1,274 +1,5 @@ -#!/bin/bash -# Deploy the fine-tuned Jerboa Qwen model to RunPod Serverless via HuggingFace. -# -# This script: -# 1. Downloads the pre-merged model from Together AI (~14GB, merged server-side) -# 2. Creates a HuggingFace repo and uploads the model -# 3. Creates a RunPod serverless vLLM endpoint via GraphQL API -# -# Prerequisites: -# pip install together huggingface_hub -# export TOGETHER_API_KEY="your-key" -# export RUNPOD_API_KEY="your-key" # from https://www.runpod.io/console/user/settings -# hf auth login # needs write token from https://huggingface.co/settings/tokens -# -# Usage: -# ./deploy_runpod.sh [HF_REPO] -# -# Example: -# ./deploy_runpod.sh jaimef21/jerboa-qwen-7b -# -# JOB_ID is auto-detected from .together_state.json (set by train_together.py). -# Override with: JOB_ID=ft-xxx ./deploy_runpod.sh ... +#!/bin/sh +set -eu -set -euo pipefail -cd "$(dirname "$0")" - -HF_REPO="${1:-jaimef21/jerboa-qwen-7b}" -MERGED_DIR="./together-merged" - -# Auto-detect JOB_ID from state file unless overridden -if [ -z "${JOB_ID:-}" ]; then - if [ -f ".together_state.json" ]; then - JOB_ID=$(python3 -c "import json; print(json.load(open('.together_state.json')).get('job_id',''))") - fi -fi - -if [ -z "${JOB_ID:-}" ]; then - echo "ERROR: No JOB_ID found." - echo "Either set it manually: JOB_ID=ft-xxx ./deploy_runpod.sh ..." - echo "Or train first: python3 train_together.py train" - exit 1 -fi - -echo "=== Deploying jerboa-qwen to RunPod via HuggingFace ===" -echo "HuggingFace repo: $HF_REPO" -echo "Together AI job: $JOB_ID" -echo "" - -# ── Check prerequisites ────────────────────────────────────────────── -if ! command -v together &>/dev/null; then - echo "ERROR: 'together' CLI not found. Run: pip install together" - exit 1 -fi - -if [ -z "${TOGETHER_API_KEY:-}" ]; then - echo "ERROR: TOGETHER_API_KEY not set. Run: export TOGETHER_API_KEY=your-key" - exit 1 -fi - -if [ -z "${RUNPOD_API_KEY:-}" ]; then - echo "ERROR: RUNPOD_API_KEY not set." - echo "Get your key from: https://www.runpod.io/console/user/settings" - echo "Then run: export RUNPOD_API_KEY=your-key" - exit 1 -fi - -python3 -c "from huggingface_hub import HfApi; HfApi().whoami()" 2>/dev/null || { - echo "ERROR: Not logged into HuggingFace. Run: hf auth login" - exit 1 -} - -# ── Step 1: Download pre-merged model from Together AI ─────────────── -echo "=== Step 1: Download pre-merged model from Together AI ===" -if [ -d "$MERGED_DIR" ] && [ -f "$MERGED_DIR/config.json" ]; then - echo "Merged model already downloaded at $MERGED_DIR" -else - echo "Downloading merged model for job $JOB_ID (~14GB) ..." - echo "Together AI merges the LoRA adapter with the base model server-side." - echo "No local GPU or 32GB RAM needed." - mkdir -p "$MERGED_DIR" - together fine-tuning download "$JOB_ID" \ - --checkpoint-type merged \ - --output_dir "$MERGED_DIR" - - # Together AI downloads a zstd-compressed tar — extract it - if [ ! -f "$MERGED_DIR/config.json" ]; then - echo "Extracting compressed archive ..." - cd "$MERGED_DIR" - for f in *; do - if file "$f" | grep -q "Zstandard"; then - tar --zstd -xf "$f" && rm "$f" - break - fi - done - cd .. - fi - - echo "Downloaded to $MERGED_DIR" -fi - -if [ ! -f "$MERGED_DIR/config.json" ]; then - echo "ERROR: config.json not found in $MERGED_DIR" - ls -la "$MERGED_DIR"/ - exit 1 -fi - -echo "" -echo "=== Step 2: Upload to HuggingFace ===" -echo "Uploading to https://huggingface.co/$HF_REPO ..." - -python3 -c " -from huggingface_hub import HfApi, create_repo -api = HfApi() - -try: - create_repo('$HF_REPO', repo_type='model', exist_ok=True) - print('Repo ready: $HF_REPO') -except Exception as e: - print(f'Repo creation: {e}') - -api.upload_folder( - folder_path='$MERGED_DIR', - repo_id='$HF_REPO', - commit_message='Upload Jerboa Qwen 7B - fine-tuned for Jerboa Scheme', -) -print('Upload complete!') -" - -echo "" -echo "Model uploaded to: https://huggingface.co/$HF_REPO" - -echo "" -echo "=== Step 3: Create RunPod Serverless Endpoint ===" - -echo "Creating RunPod serverless template ..." -TEMPLATE_RESULT=$(curl -s --request POST \ - --header 'content-type: application/json' \ - --url "https://api.runpod.io/graphql?api_key=${RUNPOD_API_KEY}" \ - --data "{\"query\": \"mutation { saveTemplate(input: { name: \\\"jerboa-qwen-vllm\\\", imageName: \\\"runpod/worker-v1-vllm:stable-cuda12.1.0\\\", isServerless: true, containerDiskInGb: 20, dockerArgs: \\\"\\\", volumeInGb: 0, env: [ { key: \\\"MODEL_NAME\\\", value: \\\"$HF_REPO\\\" }, { key: \\\"MAX_MODEL_LEN\\\", value: \\\"8192\\\" }, { key: \\\"GPU_MEMORY_UTILIZATION\\\", value: \\\"0.90\\\" } ] }) { id name imageName } }\"}") - -TEMPLATE_ID=$(echo "$TEMPLATE_RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['data']['saveTemplate']['id'])" 2>/dev/null || true) - -if [ -z "$TEMPLATE_ID" ]; then - echo "ERROR: Failed to create template." - echo "Response: $TEMPLATE_RESULT" - echo "" - echo "You may need to create the endpoint manually via the RunPod UI." - echo "See instructions below." - MANUAL=1 -else - echo "Template created: $TEMPLATE_ID" - - echo "Creating serverless endpoint ..." - ENDPOINT_RESULT=$(curl -s --request POST \ - --header 'content-type: application/json' \ - --url "https://api.runpod.io/graphql?api_key=${RUNPOD_API_KEY}" \ - --data "{\"query\": \"mutation { saveEndpoint(input: { name: \\\"jerboa-qwen\\\", templateId: \\\"$TEMPLATE_ID\\\", gpuIds: \\\"AMPERE_24\\\", workersMin: 0, workersMax: 1, idleTimeout: 60, scalerType: \\\"QUEUE_DELAY\\\", scalerValue: 4 }) { id name gpuIds templateId workersMin workersMax idleTimeout } }\"}") - - ENDPOINT_ID=$(echo "$ENDPOINT_RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['data']['saveEndpoint']['id'])" 2>/dev/null || true) - - if [ -z "$ENDPOINT_ID" ]; then - echo "ERROR: Failed to create endpoint." - echo "Response: $ENDPOINT_RESULT" - MANUAL=1 - else - MANUAL=0 - echo "" - echo "=== Endpoint Created! ===" - echo "" - echo " Endpoint ID: $ENDPOINT_ID" - echo " API URL: https://api.runpod.ai/v2/$ENDPOINT_ID/openai/v1" - echo " Console: https://www.runpod.io/console/serverless/$ENDPOINT_ID" - echo " GPU: AMPERE_24 (~\$0.39/hr)" - echo " Min Workers: 0 (scale to zero)" - echo " Max Workers: 1" - echo " Idle Timeout: 60s" - fi -fi - -if [ "${MANUAL:-0}" = "1" ]; then - echo "" - echo "=== Manual RunPod Setup ==="